]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : '';
return JSON.stringify({
code: 200,
episodes: matches,
detailUrl: detailUrl,
videoInfo: {
title: titleText,
desc: descText,
source_name: API_SITES[sourceCode].name,
source_code: sourceCode
}
});
} catch (error) {
console.error(`${API_SITES[sourceCode].name}详情获取失败:`, error);
throw error;
}
}
// 处理聚合搜索
async function handleAggregatedSearch(searchQuery) {
// 获取可用的API源列表(排除aggregated和custom)
const availableSources = Object.keys(API_SITES).filter(key =>
key !== 'aggregated' && key !== 'custom'
);
if (availableSources.length === 0) {
throw new Error('没有可用的API源');
}
// 创建所有API源的搜索请求
const searchPromises = availableSources.map(async (source) => {
try {
const apiUrl = `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;
// 使用Promise.race添加超时处理
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${source}源搜索超时`)), 8000)
);
// 添加鉴权参数到代理URL
const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ?
await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) :
PROXY_URL + encodeURIComponent(apiUrl);
const fetchPromise = fetch(proxiedUrl, {
headers: API_CONFIG.search.headers
});
const response = await Promise.race([fetchPromise, timeoutPromise]);
if (!response.ok) {
throw new Error(`${source}源请求失败: ${response.status}`);
}
const data = await response.json();
if (!data || !Array.isArray(data.list)) {
throw new Error(`${source}源返回的数据格式无效`);
}
// 为搜索结果添加源信息
const results = data.list.map(item => ({
...item,
source_name: API_SITES[source].name,
source_code: source
}));
return results;
} catch (error) {
console.warn(`${source}源搜索失败:`, error);
return []; // 返回空数组表示该源搜索失败
}
});
try {
// 并行执行所有搜索请求
const resultsArray = await Promise.all(searchPromises);
// 合并所有结果
let allResults = [];
resultsArray.forEach(results => {
if (Array.isArray(results) && results.length > 0) {
allResults = allResults.concat(results);
}
});
// 如果没有搜索结果,返回空结果
if (allResults.length === 0) {
return JSON.stringify({
code: 200,
list: [],
msg: '所有源均无搜索结果'
});
}
// 去重(根据vod_id和source_code组合)
const uniqueResults = [];
const seen = new Set();
allResults.forEach(item => {
const key = `${item.source_code}_${item.vod_id}`;
if (!seen.has(key)) {
seen.add(key);
uniqueResults.push(item);
}
});
// 按照视频名称和来源排序
uniqueResults.sort((a, b) => {
// 首先按照视频名称排序
const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || '');
if (nameCompare !== 0) return nameCompare;
// 如果名称相同,则按照来源排序
return (a.source_name || '').localeCompare(b.source_name || '');
});
return JSON.stringify({
code: 200,
list: uniqueResults,
});
} catch (error) {
console.error('聚合搜索处理错误:', error);
return JSON.stringify({
code: 400,
msg: '聚合搜索处理失败: ' + error.message,
list: []
});
}
}
// 处理多个自定义API源的聚合搜索
async function handleMultipleCustomSearch(searchQuery, customApiUrls) {
// 解析自定义API列表
const apiUrls = customApiUrls.split(CUSTOM_API_CONFIG.separator)
.map(url => url.trim())
.filter(url => url.length > 0 && /^https?:\/\//.test(url))
.slice(0, CUSTOM_API_CONFIG.maxSources);
if (apiUrls.length === 0) {
throw new Error('没有提供有效的自定义API地址');
}
// 为每个API创建搜索请求
const searchPromises = apiUrls.map(async (apiUrl, index) => {
try {
const fullUrl = `${apiUrl}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;
// 使用Promise.race添加超时处理
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`自定义API ${index+1} 搜索超时`)), 8000)
);
// 添加鉴权参数到代理URL
const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ?
await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(fullUrl)) :
PROXY_URL + encodeURIComponent(fullUrl);
const fetchPromise = fetch(proxiedUrl, {
headers: API_CONFIG.search.headers
});
const response = await Promise.race([fetchPromise, timeoutPromise]);
if (!response.ok) {
throw new Error(`自定义API ${index+1} 请求失败: ${response.status}`);
}
const data = await response.json();
if (!data || !Array.isArray(data.list)) {
throw new Error(`自定义API ${index+1} 返回的数据格式无效`);
}
// 为搜索结果添加源信息
const results = data.list.map(item => ({
...item,
source_name: `${CUSTOM_API_CONFIG.namePrefix}${index+1}`,
source_code: 'custom',
api_url: apiUrl // 保存API URL以便详情获取
}));
return results;
} catch (error) {
console.warn(`自定义API ${index+1} 搜索失败:`, error);
return []; // 返回空数组表示该源搜索失败
}
});
try {
// 并行执行所有搜索请求
const resultsArray = await Promise.all(searchPromises);
// 合并所有结果
let allResults = [];
resultsArray.forEach(results => {
if (Array.isArray(results) && results.length > 0) {
allResults = allResults.concat(results);
}
});
// 如果没有搜索结果,返回空结果
if (allResults.length === 0) {
return JSON.stringify({
code: 200,
list: [],
msg: '所有自定义API源均无搜索结果'
});
}
// 去重(根据vod_id和api_url组合)
const uniqueResults = [];
const seen = new Set();
allResults.forEach(item => {
const key = `${item.api_url || ''}_${item.vod_id}`;
if (!seen.has(key)) {
seen.add(key);
uniqueResults.push(item);
}
});
return JSON.stringify({
code: 200,
list: uniqueResults,
});
} catch (error) {
console.error('自定义API聚合搜索处理错误:', error);
return JSON.stringify({
code: 400,
msg: '自定义API聚合搜索处理失败: ' + error.message,
list: []
});
}
}
// 拦截API请求
(function() {
const originalFetch = window.fetch;
window.fetch = async function(input, init) {
const requestUrl = typeof input === 'string' ? new URL(input, window.location.origin) : input.url;
if (requestUrl.pathname.startsWith('/api/')) {
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
return;
}
}
try {
const data = await handleApiRequest(requestUrl);
return new Response(data, {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
});
} catch (error) {
return new Response(JSON.stringify({
code: 500,
msg: '服务器内部错误',
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
// 非API请求使用原始fetch
return originalFetch.apply(this, arguments);
};
})();
async function testSiteAvailability(apiUrl) {
try {
// 使用更简单的测试查询
const response = await fetch('/api/search?wd=test&customApi=' + encodeURIComponent(apiUrl), {
// 添加超时
signal: AbortSignal.timeout(5000)
});
// 检查响应状态
if (!response.ok) {
return false;
}
const data = await response.json();
// 检查API响应的有效性
return data && data.code !== 400 && Array.isArray(data.list);
} catch (error) {
console.error('站点可用性测试失败:', error);
return false;
}
}
================================================
FILE: js/app.js
================================================
// 全局变量
let selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '["tyyszy","dyttzy", "bfzy", "ruyi"]'); // 默认选中资源
let customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); // 存储自定义API列表
// 添加当前播放的集数索引
let currentEpisodeIndex = 0;
// 添加当前视频的所有集数
let currentEpisodes = [];
// 添加当前视频的标题
let currentVideoTitle = '';
// 全局变量用于倒序状态
let episodesReversed = false;
// 页面初始化
document.addEventListener('DOMContentLoaded', function () {
// 初始化API复选框
initAPICheckboxes();
// 初始化自定义API列表
renderCustomAPIsList();
// 初始化显示选中的API数量
updateSelectedApiCount();
// 渲染搜索历史
renderSearchHistory();
// 设置默认API选择(如果是第一次加载)
if (!localStorage.getItem('hasInitializedDefaults')) {
// 默认选中资源
selectedAPIs = ["tyyszy", "bfzy", "dyttzy", "ruyi"];
localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
// 默认选中过滤开关
localStorage.setItem('yellowFilterEnabled', 'true');
localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, 'true');
// 默认启用豆瓣功能
localStorage.setItem('doubanEnabled', 'true');
// 标记已初始化默认值
localStorage.setItem('hasInitializedDefaults', 'true');
}
// 设置黄色内容过滤器开关初始状态
const yellowFilterToggle = document.getElementById('yellowFilterToggle');
if (yellowFilterToggle) {
yellowFilterToggle.checked = localStorage.getItem('yellowFilterEnabled') === 'true';
}
// 设置广告过滤开关初始状态
const adFilterToggle = document.getElementById('adFilterToggle');
if (adFilterToggle) {
adFilterToggle.checked = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true
}
// 设置事件监听器
setupEventListeners();
// 初始检查成人API选中状态
setTimeout(checkAdultAPIsSelected, 100);
});
// 初始化API复选框
function initAPICheckboxes() {
const container = document.getElementById('apiCheckboxes');
container.innerHTML = '';
// 添加普通API组标题
const normaldiv = document.createElement('div');
normaldiv.id = 'normaldiv';
normaldiv.className = 'grid grid-cols-2 gap-2';
const normalTitle = document.createElement('div');
normalTitle.className = 'api-group-title';
normalTitle.textContent = '普通资源';
normaldiv.appendChild(normalTitle);
// 创建普通API源的复选框
Object.keys(API_SITES).forEach(apiKey => {
const api = API_SITES[apiKey];
if (api.adult) return; // 跳过成人内容API,稍后添加
const checked = selectedAPIs.includes(apiKey);
const checkbox = document.createElement('div');
checkbox.className = 'flex items-center';
checkbox.innerHTML = `
${api.name}
`;
normaldiv.appendChild(checkbox);
// 添加事件监听器
checkbox.querySelector('input').addEventListener('change', function () {
updateSelectedAPIs();
checkAdultAPIsSelected();
});
});
container.appendChild(normaldiv);
// 添加成人API列表
addAdultAPI();
// 初始检查成人内容状态
checkAdultAPIsSelected();
}
// 添加成人API列表
function addAdultAPI() {
// 仅在隐藏设置为false时添加成人API组
if (!HIDE_BUILTIN_ADULT_APIS && (localStorage.getItem('yellowFilterEnabled') === 'false')) {
const container = document.getElementById('apiCheckboxes');
// 添加成人API组标题
const adultdiv = document.createElement('div');
adultdiv.id = 'adultdiv';
adultdiv.className = 'grid grid-cols-2 gap-2';
const adultTitle = document.createElement('div');
adultTitle.className = 'api-group-title adult';
adultTitle.innerHTML = `黄色资源采集站
`;
adultdiv.appendChild(adultTitle);
// 创建成人API源的复选框
Object.keys(API_SITES).forEach(apiKey => {
const api = API_SITES[apiKey];
if (!api.adult) return; // 仅添加成人内容API
const checked = selectedAPIs.includes(apiKey);
const checkbox = document.createElement('div');
checkbox.className = 'flex items-center';
checkbox.innerHTML = `
${api.name}
`;
adultdiv.appendChild(checkbox);
// 添加事件监听器
checkbox.querySelector('input').addEventListener('change', function () {
updateSelectedAPIs();
checkAdultAPIsSelected();
});
});
container.appendChild(adultdiv);
}
}
// 检查是否有成人API被选中
function checkAdultAPIsSelected() {
// 查找所有内置成人API复选框
const adultBuiltinCheckboxes = document.querySelectorAll('#apiCheckboxes .api-adult:checked');
// 查找所有自定义成人API复选框
const customApiCheckboxes = document.querySelectorAll('#customApisList .api-adult:checked');
const hasAdultSelected = adultBuiltinCheckboxes.length > 0 || customApiCheckboxes.length > 0;
const yellowFilterToggle = document.getElementById('yellowFilterToggle');
const yellowFilterContainer = yellowFilterToggle.closest('div').parentNode;
const filterDescription = yellowFilterContainer.querySelector('p.filter-description');
// 如果选择了成人API,禁用黄色内容过滤器
if (hasAdultSelected) {
yellowFilterToggle.checked = false;
yellowFilterToggle.disabled = true;
localStorage.setItem('yellowFilterEnabled', 'false');
// 添加禁用样式
yellowFilterContainer.classList.add('filter-disabled');
// 修改描述文字
if (filterDescription) {
filterDescription.innerHTML = '
选中黄色资源站时无法启用此过滤 ';
}
// 移除提示信息(如果存在)
const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip');
if (existingTooltip) {
existingTooltip.remove();
}
} else {
// 启用黄色内容过滤器
yellowFilterToggle.disabled = false;
yellowFilterContainer.classList.remove('filter-disabled');
// 恢复原来的描述文字
if (filterDescription) {
filterDescription.innerHTML = '过滤"伦理片"等黄色内容';
}
// 移除提示信息
const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip');
if (existingTooltip) {
existingTooltip.remove();
}
}
}
// 渲染自定义API列表
function renderCustomAPIsList() {
const container = document.getElementById('customApisList');
if (!container) return;
if (customAPIs.length === 0) {
container.innerHTML = '
未添加自定义API
';
return;
}
container.innerHTML = '';
customAPIs.forEach((api, index) => {
const apiItem = document.createElement('div');
apiItem.className = 'flex items-center justify-between p-1 mb-1 bg-[#222] rounded';
const textColorClass = api.isAdult ? 'text-pink-400' : 'text-white';
const adultTag = api.isAdult ? '
(18+) ' : '';
// 新增 detail 地址显示
const detailLine = api.detail ? `
detail: ${api.detail}
` : '';
apiItem.innerHTML = `
${adultTag}${api.name}
${api.url}
${detailLine}
✎
✕
`;
container.appendChild(apiItem);
apiItem.querySelector('input').addEventListener('change', function () {
updateSelectedAPIs();
checkAdultAPIsSelected();
});
});
}
// 编辑自定义API
function editCustomApi(index) {
if (index < 0 || index >= customAPIs.length) return;
const api = customAPIs[index];
document.getElementById('customApiName').value = api.name;
document.getElementById('customApiUrl').value = api.url;
document.getElementById('customApiDetail').value = api.detail || '';
const isAdultInput = document.getElementById('customApiIsAdult');
if (isAdultInput) isAdultInput.checked = api.isAdult || false;
const form = document.getElementById('addCustomApiForm');
if (form) {
form.classList.remove('hidden');
const buttonContainer = form.querySelector('div:last-child');
buttonContainer.innerHTML = `
更新
取消
`;
}
}
// 更新自定义API
function updateCustomApi(index) {
if (index < 0 || index >= customAPIs.length) return;
const nameInput = document.getElementById('customApiName');
const urlInput = document.getElementById('customApiUrl');
const detailInput = document.getElementById('customApiDetail');
const isAdultInput = document.getElementById('customApiIsAdult');
const name = nameInput.value.trim();
let url = urlInput.value.trim();
const detail = detailInput ? detailInput.value.trim() : '';
const isAdult = isAdultInput ? isAdultInput.checked : false;
if (!name || !url) {
showToast('请输入API名称和链接', 'warning');
return;
}
if (!/^https?:\/\/.+/.test(url)) {
showToast('API链接格式不正确,需以http://或https://开头', 'warning');
return;
}
if (url.endsWith('/')) url = url.slice(0, -1);
// 保存 detail 字段
customAPIs[index] = { name, url, detail, isAdult };
localStorage.setItem('customAPIs', JSON.stringify(customAPIs));
renderCustomAPIsList();
checkAdultAPIsSelected();
restoreAddCustomApiButtons();
nameInput.value = '';
urlInput.value = '';
if (detailInput) detailInput.value = '';
if (isAdultInput) isAdultInput.checked = false;
document.getElementById('addCustomApiForm').classList.add('hidden');
showToast('已更新自定义API: ' + name, 'success');
}
// 取消编辑自定义API
function cancelEditCustomApi() {
// 清空表单
document.getElementById('customApiName').value = '';
document.getElementById('customApiUrl').value = '';
document.getElementById('customApiDetail').value = '';
const isAdultInput = document.getElementById('customApiIsAdult');
if (isAdultInput) isAdultInput.checked = false;
// 隐藏表单
document.getElementById('addCustomApiForm').classList.add('hidden');
// 恢复添加按钮
restoreAddCustomApiButtons();
}
// 恢复自定义API添加按钮
function restoreAddCustomApiButtons() {
const form = document.getElementById('addCustomApiForm');
const buttonContainer = form.querySelector('div:last-child');
buttonContainer.innerHTML = `
添加
取消
`;
}
// 更新选中的API列表
function updateSelectedAPIs() {
// 获取所有内置API复选框
const builtInApiCheckboxes = document.querySelectorAll('#apiCheckboxes input:checked');
// 获取选中的内置API
const builtInApis = Array.from(builtInApiCheckboxes).map(input => input.dataset.api);
// 获取选中的自定义API
const customApiCheckboxes = document.querySelectorAll('#customApisList input:checked');
const customApiIndices = Array.from(customApiCheckboxes).map(input => 'custom_' + input.dataset.customIndex);
// 合并内置和自定义API
selectedAPIs = [...builtInApis, ...customApiIndices];
// 保存到localStorage
localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
// 更新显示选中的API数量
updateSelectedApiCount();
}
// 更新选中的API数量显示
function updateSelectedApiCount() {
const countEl = document.getElementById('selectedApiCount');
if (countEl) {
countEl.textContent = selectedAPIs.length;
}
}
// 全选或取消全选API
function selectAllAPIs(selectAll = true, excludeAdult = false) {
const checkboxes = document.querySelectorAll('#apiCheckboxes input[type="checkbox"]');
checkboxes.forEach(checkbox => {
if (excludeAdult && checkbox.classList.contains('api-adult')) {
checkbox.checked = false;
} else {
checkbox.checked = selectAll;
}
});
updateSelectedAPIs();
checkAdultAPIsSelected();
}
// 显示添加自定义API表单
function showAddCustomApiForm() {
const form = document.getElementById('addCustomApiForm');
if (form) {
form.classList.remove('hidden');
}
}
// 取消添加自定义API - 修改函数来重用恢复按钮逻辑
function cancelAddCustomApi() {
const form = document.getElementById('addCustomApiForm');
if (form) {
form.classList.add('hidden');
document.getElementById('customApiName').value = '';
document.getElementById('customApiUrl').value = '';
document.getElementById('customApiDetail').value = '';
const isAdultInput = document.getElementById('customApiIsAdult');
if (isAdultInput) isAdultInput.checked = false;
// 确保按钮是添加按钮
restoreAddCustomApiButtons();
}
}
// 添加自定义API
function addCustomApi() {
const nameInput = document.getElementById('customApiName');
const urlInput = document.getElementById('customApiUrl');
const detailInput = document.getElementById('customApiDetail');
const isAdultInput = document.getElementById('customApiIsAdult');
const name = nameInput.value.trim();
let url = urlInput.value.trim();
const detail = detailInput ? detailInput.value.trim() : '';
const isAdult = isAdultInput ? isAdultInput.checked : false;
if (!name || !url) {
showToast('请输入API名称和链接', 'warning');
return;
}
if (!/^https?:\/\/.+/.test(url)) {
showToast('API链接格式不正确,需以http://或https://开头', 'warning');
return;
}
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
// 保存 detail 字段
customAPIs.push({ name, url, detail, isAdult });
localStorage.setItem('customAPIs', JSON.stringify(customAPIs));
const newApiIndex = customAPIs.length - 1;
selectedAPIs.push('custom_' + newApiIndex);
localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
// 重新渲染自定义API列表
renderCustomAPIsList();
updateSelectedApiCount();
checkAdultAPIsSelected();
nameInput.value = '';
urlInput.value = '';
if (detailInput) detailInput.value = '';
if (isAdultInput) isAdultInput.checked = false;
document.getElementById('addCustomApiForm').classList.add('hidden');
showToast('已添加自定义API: ' + name, 'success');
}
// 移除自定义API
function removeCustomApi(index) {
if (index < 0 || index >= customAPIs.length) return;
const apiName = customAPIs[index].name;
// 从列表中移除API
customAPIs.splice(index, 1);
localStorage.setItem('customAPIs', JSON.stringify(customAPIs));
// 从选中列表中移除此API
const customApiId = 'custom_' + index;
selectedAPIs = selectedAPIs.filter(id => id !== customApiId);
// 更新大于此索引的自定义API索引
selectedAPIs = selectedAPIs.map(id => {
if (id.startsWith('custom_')) {
const currentIndex = parseInt(id.replace('custom_', ''));
if (currentIndex > index) {
return 'custom_' + (currentIndex - 1);
}
}
return id;
});
localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
// 重新渲染自定义API列表
renderCustomAPIsList();
// 更新选中的API数量
updateSelectedApiCount();
// 重新检查成人API选中状态
checkAdultAPIsSelected();
showToast('已移除自定义API: ' + apiName, 'info');
}
function toggleSettings(e) {
const settingsPanel = document.getElementById('settingsPanel');
if (!settingsPanel) return;
if (settingsPanel.classList.contains('show')) {
settingsPanel.classList.remove('show');
} else {
settingsPanel.classList.add('show');
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
}
// 设置事件监听器
function setupEventListeners() {
// 回车搜索
document.getElementById('searchInput').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
search();
}
});
// 点击外部关闭设置面板和历史记录面板
document.addEventListener('click', function (e) {
// 关闭设置面板
const settingsPanel = document.querySelector('#settingsPanel.show');
const settingsButton = document.querySelector('#settingsPanel .close-btn');
if (settingsPanel && settingsButton &&
!settingsPanel.contains(e.target) &&
!settingsButton.contains(e.target)) {
settingsPanel.classList.remove('show');
}
// 关闭历史记录面板
const historyPanel = document.querySelector('#historyPanel.show');
const historyButton = document.querySelector('#historyPanel .close-btn');
if (historyPanel && historyButton &&
!historyPanel.contains(e.target) &&
!historyButton.contains(e.target)) {
historyPanel.classList.remove('show');
}
});
// 黄色内容过滤开关事件绑定
const yellowFilterToggle = document.getElementById('yellowFilterToggle');
if (yellowFilterToggle) {
yellowFilterToggle.addEventListener('change', function (e) {
localStorage.setItem('yellowFilterEnabled', e.target.checked);
// 控制黄色内容接口的显示状态
const adultdiv = document.getElementById('adultdiv');
if (adultdiv) {
if (e.target.checked === true) {
adultdiv.style.display = 'none';
} else if (e.target.checked === false) {
adultdiv.style.display = ''
}
} else {
// 添加成人API列表
addAdultAPI();
}
});
}
// 广告过滤开关事件绑定
const adFilterToggle = document.getElementById('adFilterToggle');
if (adFilterToggle) {
adFilterToggle.addEventListener('change', function (e) {
localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, e.target.checked);
});
}
}
// 重置搜索区域
function resetSearchArea() {
// 清理搜索结果
document.getElementById('results').innerHTML = '';
document.getElementById('searchInput').value = '';
// 恢复搜索区域的样式
document.getElementById('searchArea').classList.add('flex-1');
document.getElementById('searchArea').classList.remove('mb-8');
document.getElementById('resultsArea').classList.add('hidden');
// 确保页脚正确显示,移除相对定位
const footer = document.querySelector('.footer');
if (footer) {
footer.style.position = '';
}
// 如果有豆瓣功能,检查是否需要显示豆瓣推荐区域
if (typeof updateDoubanVisibility === 'function') {
updateDoubanVisibility();
}
// 重置URL为主页
try {
window.history.pushState(
{},
`LibreTV - 免费在线视频搜索与观看平台`,
`/`
);
// 更新页面标题
document.title = `LibreTV - 免费在线视频搜索与观看平台`;
} catch (e) {
console.error('更新浏览器历史失败:', e);
}
}
// 获取自定义API信息
function getCustomApiInfo(customApiIndex) {
const index = parseInt(customApiIndex);
if (isNaN(index) || index < 0 || index >= customAPIs.length) {
return null;
}
return customAPIs[index];
}
// 搜索功能 - 修改为支持多选API和多页结果
async function search() {
// 强化的密码保护校验 - 防止绕过
try {
if (window.ensurePasswordProtection) {
window.ensurePasswordProtection();
} else {
// 兼容性检查
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
showPasswordModal && showPasswordModal();
return;
}
}
}
} catch (error) {
console.warn('Password protection check failed:', error.message);
return;
}
const query = document.getElementById('searchInput').value.trim();
if (!query) {
showToast('请输入搜索内容', 'info');
return;
}
if (selectedAPIs.length === 0) {
showToast('请至少选择一个API源', 'warning');
return;
}
showLoading();
try {
// 保存搜索历史
saveSearchHistory(query);
// 从所有选中的API源搜索
let allResults = [];
const searchPromises = selectedAPIs.map(apiId =>
searchByAPIAndKeyWord(apiId, query)
);
// 等待所有搜索请求完成
const resultsArray = await Promise.all(searchPromises);
// 合并所有结果
resultsArray.forEach(results => {
if (Array.isArray(results) && results.length > 0) {
allResults = allResults.concat(results);
}
});
// 对搜索结果进行排序:按名称优先,名称相同时按接口源排序
allResults.sort((a, b) => {
// 首先按照视频名称排序
const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || '');
if (nameCompare !== 0) return nameCompare;
// 如果名称相同,则按照来源排序
return (a.source_name || '').localeCompare(b.source_name || '');
});
// 更新搜索结果计数
const searchResultsCount = document.getElementById('searchResultsCount');
if (searchResultsCount) {
searchResultsCount.textContent = allResults.length;
}
// 显示结果区域,调整搜索区域
document.getElementById('searchArea').classList.remove('flex-1');
document.getElementById('searchArea').classList.add('mb-8');
document.getElementById('resultsArea').classList.remove('hidden');
// 隐藏豆瓣推荐区域(如果存在)
const doubanArea = document.getElementById('doubanArea');
if (doubanArea) {
doubanArea.classList.add('hidden');
}
const resultsDiv = document.getElementById('results');
// 如果没有结果
if (!allResults || allResults.length === 0) {
resultsDiv.innerHTML = `
`;
hideLoading();
return;
}
// 有搜索结果时,才更新URL
try {
// 使用URI编码确保特殊字符能够正确显示
const encodedQuery = encodeURIComponent(query);
// 使用HTML5 History API更新URL,不刷新页面
window.history.pushState(
{ search: query },
`搜索: ${query} - LibreTV`,
`/s=${encodedQuery}`
);
// 更新页面标题
document.title = `搜索: ${query} - LibreTV`;
} catch (e) {
console.error('更新浏览器历史失败:', e);
// 如果更新URL失败,继续执行搜索
}
// 处理搜索结果过滤:如果启用了黄色内容过滤,则过滤掉分类含有敏感内容的项目
const yellowFilterEnabled = localStorage.getItem('yellowFilterEnabled') === 'true';
if (yellowFilterEnabled) {
const banned = ['伦理片', '福利', '里番动漫', '门事件', '萝莉少女', '制服诱惑', '国产传媒', 'cosplay', '黑丝诱惑', '无码', '日本无码', '有码', '日本有码', 'SWAG', '网红主播', '色情片', '同性片', '福利视频', '福利片'];
allResults = allResults.filter(item => {
const typeName = item.type_name || '';
return !banned.some(keyword => typeName.includes(keyword));
});
}
// 添加XSS保护,使用textContent和属性转义
const safeResults = allResults.map(item => {
const safeId = item.vod_id ? item.vod_id.toString().replace(/[^\w-]/g, '') : '';
const safeName = (item.vod_name || '').toString()
.replace(//g, '>')
.replace(/"/g, '"');
const sourceInfo = item.source_name ?
`
${item.source_name} ` : '';
const sourceCode = item.source_code || '';
// 添加API URL属性,用于详情获取
const apiUrlAttr = item.api_url ?
`data-api-url="${item.api_url.replace(/"/g, '"')}"` : '';
// 修改为水平卡片布局,图片在左侧,文本在右侧,并优化样式
const hasCover = item.vod_pic && item.vod_pic.startsWith('http');
return `
${hasCover ? `
` : ''}
${safeName}
${(item.type_name || '').toString().replace(/
${(item.type_name || '').toString().replace(/` : ''}
${(item.vod_year || '') ?
`
${item.vod_year}
` : ''}
${(item.vod_remarks || '暂无介绍').toString().replace(/
${sourceInfo ? `
${sourceInfo}
` : '
'}
`;
}).join('');
resultsDiv.innerHTML = safeResults;
} catch (error) {
console.error('搜索错误:', error);
if (error.name === 'AbortError') {
showToast('搜索请求超时,请检查网络连接', 'error');
} else {
showToast('搜索请求失败,请稍后重试', 'error');
}
} finally {
hideLoading();
}
}
// 切换清空按钮的显示状态
function toggleClearButton() {
const searchInput = document.getElementById('searchInput');
const clearButton = document.getElementById('clearSearchInput');
if (searchInput.value !== '') {
clearButton.classList.remove('hidden');
} else {
clearButton.classList.add('hidden');
}
}
// 清空搜索框内容
function clearSearchInput() {
const searchInput = document.getElementById('searchInput');
searchInput.value = '';
const clearButton = document.getElementById('clearSearchInput');
clearButton.classList.add('hidden');
}
// 劫持搜索框的value属性以检测外部修改
function hookInput() {
const input = document.getElementById('searchInput');
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
// 重写 value 属性的 getter 和 setter
Object.defineProperty(input, 'value', {
get: function () {
// 确保读取时返回字符串(即使原始值为 undefined/null)
const originalValue = descriptor.get.call(this);
return originalValue != null ? String(originalValue) : '';
},
set: function (value) {
// 显式将值转换为字符串后写入
const strValue = String(value);
descriptor.set.call(this, strValue);
this.dispatchEvent(new Event('input', { bubbles: true }));
}
});
// 初始化输入框值为空字符串(避免初始值为 undefined)
input.value = '';
}
document.addEventListener('DOMContentLoaded', hookInput);
// 显示详情 - 修改为支持自定义API
async function showDetails(id, vod_name, sourceCode) {
// 密码保护校验
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
showPasswordModal && showPasswordModal();
return;
}
}
if (!id) {
showToast('视频ID无效', 'error');
return;
}
showLoading();
try {
// 构建API参数
let apiParams = '';
// 处理自定义API源
if (sourceCode.startsWith('custom_')) {
const customIndex = sourceCode.replace('custom_', '');
const customApi = getCustomApiInfo(customIndex);
if (!customApi) {
showToast('自定义API配置无效', 'error');
hideLoading();
return;
}
// 传递 detail 字段
if (customApi.detail) {
apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom';
} else {
apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom';
}
} else {
// 内置API
apiParams = '&source=' + sourceCode;
}
// Add a timestamp to prevent caching
const timestamp = new Date().getTime();
const cacheBuster = `&_t=${timestamp}`;
const response = await fetch(`/api/detail?id=${encodeURIComponent(id)}${apiParams}${cacheBuster}`);
const data = await response.json();
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modalTitle');
const modalContent = document.getElementById('modalContent');
// 显示来源信息
const sourceName = data.videoInfo && data.videoInfo.source_name ?
`
(${data.videoInfo.source_name}) ` : '';
// 不对标题进行截断处理,允许完整显示
modalTitle.innerHTML = `
${vod_name || '未知视频'} ${sourceName}`;
currentVideoTitle = vod_name || '未知视频';
if (data.episodes && data.episodes.length > 0) {
// 构建详情信息HTML
let detailInfoHtml = '';
if (data.videoInfo) {
// Prepare description text, strip HTML and trim whitespace
const descriptionText = data.videoInfo.desc ? data.videoInfo.desc.replace(/<[^>]+>/g, '').trim() : '';
// Check if there's any actual grid content
const hasGridContent = data.videoInfo.type || data.videoInfo.year || data.videoInfo.area || data.videoInfo.director || data.videoInfo.actor || data.videoInfo.remarks;
if (hasGridContent || descriptionText) { // Only build if there's something to show
detailInfoHtml = `
${hasGridContent ? `
${data.videoInfo.type ? `
类型: ${data.videoInfo.type}
` : ''}
${data.videoInfo.year ? `
年份: ${data.videoInfo.year}
` : ''}
${data.videoInfo.area ? `
地区: ${data.videoInfo.area}
` : ''}
${data.videoInfo.director ? `
导演: ${data.videoInfo.director}
` : ''}
${data.videoInfo.actor ? `
主演: ${data.videoInfo.actor}
` : ''}
${data.videoInfo.remarks ? `
备注: ${data.videoInfo.remarks}
` : ''}
` : ''}
${descriptionText ? `
` : ''}
`;
}
}
currentEpisodes = data.episodes;
currentEpisodeIndex = 0;
modalContent.innerHTML = `
${detailInfoHtml}
${episodesReversed ? '正序排列' : '倒序排列'}
共 ${data.episodes.length} 集
复制链接
${renderEpisodes(vod_name, sourceCode, id)}
`;
} else {
modalContent.innerHTML = `
❌ 未找到播放资源
该视频可能暂时无法播放,请尝试其他视频
`;
}
modal.classList.remove('hidden');
} catch (error) {
console.error('获取详情错误:', error);
showToast('获取详情失败,请稍后重试', 'error');
} finally {
hideLoading();
}
}
// 更新播放视频函数,修改为使用/watch路径而不是直接打开player.html
function playVideo(url, vod_name, sourceCode, episodeIndex = 0, vodId = '') {
// 密码保护校验
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
showPasswordModal && showPasswordModal();
return;
}
}
// 获取当前路径作为返回页面
let currentPath = window.location.href;
// 构建播放页面URL,使用watch.html作为中间跳转页
let watchUrl = `watch.html?id=${vodId || ''}&source=${sourceCode || ''}&url=${encodeURIComponent(url)}&index=${episodeIndex}&title=${encodeURIComponent(vod_name || '')}`;
// 添加返回URL参数
if (currentPath.includes('index.html') || currentPath.endsWith('/')) {
watchUrl += `&back=${encodeURIComponent(currentPath)}`;
}
// 保存当前状态到localStorage
try {
localStorage.setItem('currentVideoTitle', vod_name || '未知视频');
localStorage.setItem('currentEpisodes', JSON.stringify(currentEpisodes));
localStorage.setItem('currentEpisodeIndex', episodeIndex);
localStorage.setItem('currentSourceCode', sourceCode || '');
localStorage.setItem('lastPlayTime', Date.now());
localStorage.setItem('lastSearchPage', currentPath);
localStorage.setItem('lastPageUrl', currentPath); // 确保保存返回页面URL
} catch (e) {
console.error('保存播放状态失败:', e);
}
// 在当前标签页中打开播放页面
window.location.href = watchUrl;
}
// 弹出播放器页面
function showVideoPlayer(url) {
// 在打开播放器前,隐藏详情弹窗
const detailModal = document.getElementById('modal');
if (detailModal) {
detailModal.classList.add('hidden');
}
// 临时隐藏搜索结果和豆瓣区域,防止高度超出播放器而出现滚动条
document.getElementById('resultsArea').classList.add('hidden');
document.getElementById('doubanArea').classList.add('hidden');
// 在框架中打开播放页面
videoPlayerFrame = document.createElement('iframe');
videoPlayerFrame.id = 'VideoPlayerFrame';
videoPlayerFrame.className = 'fixed w-full h-screen z-40';
videoPlayerFrame.src = url;
document.body.appendChild(videoPlayerFrame);
// 将焦点移入iframe
videoPlayerFrame.focus();
}
// 关闭播放器页面
function closeVideoPlayer(home = false) {
videoPlayerFrame = document.getElementById('VideoPlayerFrame');
if (videoPlayerFrame) {
videoPlayerFrame.remove();
// 恢复搜索结果显示
document.getElementById('resultsArea').classList.remove('hidden');
// 关闭播放器时也隐藏详情弹窗
const detailModal = document.getElementById('modal');
if (detailModal) {
detailModal.classList.add('hidden');
}
// 如果启用豆瓣区域则显示豆瓣区域
if (localStorage.getItem('doubanEnabled') === 'true') {
document.getElementById('doubanArea').classList.remove('hidden');
}
}
if (home) {
// 刷新主页
window.location.href = '/'
}
}
// 播放上一集
function playPreviousEpisode(sourceCode) {
if (currentEpisodeIndex > 0) {
const prevIndex = currentEpisodeIndex - 1;
const prevUrl = currentEpisodes[prevIndex];
playVideo(prevUrl, currentVideoTitle, sourceCode, prevIndex);
}
}
// 播放下一集
function playNextEpisode(sourceCode) {
if (currentEpisodeIndex < currentEpisodes.length - 1) {
const nextIndex = currentEpisodeIndex + 1;
const nextUrl = currentEpisodes[nextIndex];
playVideo(nextUrl, currentVideoTitle, sourceCode, nextIndex);
}
}
// 处理播放器加载错误
function handlePlayerError() {
hideLoading();
showToast('视频播放加载失败,请尝试其他视频源', 'error');
}
// 辅助函数用于渲染剧集按钮(使用当前的排序状态)
function renderEpisodes(vodName, sourceCode, vodId) {
const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;
return episodes.map((episode, index) => {
// 根据倒序状态计算真实的剧集索引
const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index;
return `
${realIndex + 1}
`;
}).join('');
}
// 复制视频链接到剪贴板
function copyLinks() {
const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;
const linkList = episodes.join('\r\n');
navigator.clipboard.writeText(linkList).then(() => {
showToast('播放链接已复制', 'success');
}).catch(err => {
showToast('复制失败,请检查浏览器权限', 'error');
});
}
// 切换排序状态的函数
function toggleEpisodeOrder(sourceCode, vodId) {
episodesReversed = !episodesReversed;
// 重新渲染剧集区域,使用 currentVideoTitle 作为视频标题
const episodesGrid = document.getElementById('episodesGrid');
if (episodesGrid) {
episodesGrid.innerHTML = renderEpisodes(currentVideoTitle, sourceCode, vodId);
}
// 更新按钮文本和箭头方向
const toggleBtn = document.querySelector(`button[onclick="toggleEpisodeOrder('${sourceCode}', '${vodId}')"]`);
if (toggleBtn) {
toggleBtn.querySelector('span').textContent = episodesReversed ? '正序排列' : '倒序排列';
const arrowIcon = toggleBtn.querySelector('svg');
if (arrowIcon) {
arrowIcon.style.transform = episodesReversed ? 'rotate(180deg)' : 'rotate(0deg)';
}
}
}
// 从URL导入配置
async function importConfigFromUrl() {
// 创建模态框元素
let modal = document.getElementById('importUrlModal');
if (modal) {
document.body.removeChild(modal);
}
modal = document.createElement('div');
modal.id = 'importUrlModal';
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';
modal.innerHTML = `
`;
document.body.appendChild(modal);
// 关闭按钮事件
document.getElementById('closeUrlModal').addEventListener('click', () => {
document.body.removeChild(modal);
});
// 取消按钮事件
document.getElementById('cancelUrlImport').addEventListener('click', () => {
document.body.removeChild(modal);
});
// 确认导入按钮事件
document.getElementById('confirmUrlImport').addEventListener('click', async () => {
const url = document.getElementById('configUrl').value.trim();
if (!url) {
showToast('请输入配置文件URL', 'warning');
return;
}
// 验证URL格式
try {
const urlObj = new URL(url);
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
showToast('URL必须以http://或https://开头', 'warning');
return;
}
} catch (e) {
showToast('URL格式不正确', 'warning');
return;
}
showLoading('正在从URL导入配置...');
try {
// 获取配置文件 - 直接请求URL
const response = await fetch(url, {
mode: 'cors',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) throw '获取配置文件失败';
// 验证响应内容类型
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw '响应不是有效的JSON格式';
}
const config = await response.json();
if (config.name !== 'LibreTV-Settings') throw '配置文件格式不正确';
// 验证哈希
const dataHash = await sha256(JSON.stringify(config.data));
if (dataHash !== config.hash) throw '配置文件哈希值不匹配';
// 导入配置
for (let item in config.data) {
localStorage.setItem(item, config.data[item]);
}
showToast('配置文件导入成功,3 秒后自动刷新本页面。', 'success');
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (error) {
const message = typeof error === 'string' ? error : '导入配置失败';
showToast(`从URL导入配置出错 (${message})`, 'error');
} finally {
hideLoading();
document.body.removeChild(modal);
}
});
// 点击模态框外部关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
// 配置文件导入功能
async function importConfig() {
showImportBox(async (file) => {
try {
// 检查文件类型
if (!(file.type === 'application/json' || file.name.endsWith('.json'))) throw '文件类型不正确';
// 检查文件大小
if (file.size > 1024 * 1024 * 10) throw new Error('文件大小超过 10MB');
// 读取文件内容
const content = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject('文件读取失败');
reader.readAsText(file);
});
// 解析并验证配置
const config = JSON.parse(content);
if (config.name !== 'LibreTV-Settings') throw '配置文件格式不正确';
// 验证哈希
const dataHash = await sha256(JSON.stringify(config.data));
if (dataHash !== config.hash) throw '配置文件哈希值不匹配';
// 导入配置
for (let item in config.data) {
localStorage.setItem(item, config.data[item]);
}
showToast('配置文件导入成功,3 秒后自动刷新本页面。', 'success');
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (error) {
const message = typeof error === 'string' ? error : '配置文件格式错误';
showToast(`配置文件读取出错 (${message})`, 'error');
}
});
}
// 配置文件导出功能
async function exportConfig() {
// 存储配置数据
const config = {};
const items = {};
const settingsToExport = [
'selectedAPIs',
'customAPIs',
'yellowFilterEnabled',
'adFilteringEnabled',
'doubanEnabled',
'hasInitializedDefaults'
];
// 导出设置项
settingsToExport.forEach(key => {
const value = localStorage.getItem(key);
if (value !== null) {
items[key] = value;
}
});
// 导出历史记录
const viewingHistory = localStorage.getItem('viewingHistory');
if (viewingHistory) {
items['viewingHistory'] = viewingHistory;
}
const searchHistory = localStorage.getItem(SEARCH_HISTORY_KEY);
if (searchHistory) {
items[SEARCH_HISTORY_KEY] = searchHistory;
}
const times = Date.now().toString();
config['name'] = 'LibreTV-Settings'; // 配置文件名,用于校验
config['time'] = times; // 配置文件生成时间
config['cfgVer'] = '1.0.0'; // 配置文件版本
config['data'] = items; // 配置文件数据
config['hash'] = await sha256(JSON.stringify(config['data'])); // 计算数据的哈希值,用于校验
// 将配置数据保存为 JSON 文件
saveStringAsFile(JSON.stringify(config), 'LibreTV-Settings_' + times + '.json');
}
// 将字符串保存为文件
function saveStringAsFile(content, fileName) {
// 创建Blob对象并指定类型
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
// 生成临时URL
const url = window.URL.createObjectURL(blob);
// 创建
标签并触发下载
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
// 清理临时对象
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
// 移除Node.js的require语句,因为这是在浏览器环境中运行的
================================================
FILE: js/config.js
================================================
// 全局常量配置
const PROXY_URL = '/proxy/'; // 适用于 Cloudflare, Netlify (带重写), Vercel (带重写)
// const HOPLAYER_URL = 'https://hoplayer.com/index.html';
const SEARCH_HISTORY_KEY = 'videoSearchHistory';
const MAX_HISTORY_ITEMS = 5;
// 密码保护配置
// 注意:PASSWORD 环境变量是必需的,所有部署都必须设置密码以确保安全
const PASSWORD_CONFIG = {
localStorageKey: 'passwordVerified', // 存储验证状态的键名
verificationTTL: 90 * 24 * 60 * 60 * 1000 // 验证有效期(90天,约3个月)
};
// 网站信息配置
const SITE_CONFIG = {
name: 'LibreTV',
url: 'https://libretv.is-an.org',
description: '免费在线视频搜索与观看平台',
logo: 'image/logo.png',
version: '1.0.3'
};
// API站点配置
const API_SITES = {
testSource: {
api: 'https://www.example.com/api.php/provide/vod',
name: '空内容测试源',
adult: true
}
//ARCHIVE https://telegra.ph/APIs-08-12
};
// 定义合并方法
function extendAPISites(newSites) {
Object.assign(API_SITES, newSites);
}
// 暴露到全局
window.API_SITES = API_SITES;
window.extendAPISites = extendAPISites;
// 添加聚合搜索的配置选项
const AGGREGATED_SEARCH_CONFIG = {
enabled: true, // 是否启用聚合搜索
timeout: 8000, // 单个源超时时间(毫秒)
maxResults: 10000, // 最大结果数量
parallelRequests: true, // 是否并行请求所有源
showSourceBadges: true // 是否显示来源徽章
};
// 抽象API请求配置
const API_CONFIG = {
search: {
// 只拼接参数部分,不再包含 /api.php/provide/vod/
path: '?ac=videolist&wd=',
pagePath: '?ac=videolist&wd={query}&pg={page}',
maxPages: 50, // 最大获取页数
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': 'application/json'
}
},
detail: {
// 只拼接参数部分
path: '?ac=videolist&ids=',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': 'application/json'
}
}
};
// 优化后的正则表达式模式
const M3U8_PATTERN = /\$https?:\/\/[^"'\s]+?\.m3u8/g;
// 添加自定义播放器URL
const CUSTOM_PLAYER_URL = 'player.html'; // 使用相对路径引用本地player.html
// 增加视频播放相关配置
const PLAYER_CONFIG = {
autoplay: true,
allowFullscreen: true,
width: '100%',
height: '600',
timeout: 15000, // 播放器加载超时时间
filterAds: true, // 是否启用广告过滤
autoPlayNext: true, // 默认启用自动连播功能
adFilteringEnabled: true, // 默认开启分片广告过滤
adFilteringStorage: 'adFilteringEnabled' // 存储广告过滤设置的键名
};
// 增加错误信息本地化
const ERROR_MESSAGES = {
NETWORK_ERROR: '网络连接错误,请检查网络设置',
TIMEOUT_ERROR: '请求超时,服务器响应时间过长',
API_ERROR: 'API接口返回错误,请尝试更换数据源',
PLAYER_ERROR: '播放器加载失败,请尝试其他视频源',
UNKNOWN_ERROR: '发生未知错误,请刷新页面重试'
};
// 添加进一步安全设置
const SECURITY_CONFIG = {
enableXSSProtection: true, // 是否启用XSS保护
sanitizeUrls: true, // 是否清理URL
maxQueryLength: 100, // 最大搜索长度
// allowedApiDomains 不再需要,因为所有请求都通过内部代理
};
// 添加多个自定义API源的配置
const CUSTOM_API_CONFIG = {
separator: ',', // 分隔符
maxSources: 5, // 最大允许的自定义源数量
testTimeout: 5000, // 测试超时时间(毫秒)
namePrefix: 'Custom-', // 自定义源名称前缀
validateUrl: true, // 验证URL格式
cacheResults: true, // 缓存测试结果
cacheExpiry: 5184000000, // 缓存过期时间(2个月)
adultPropName: 'isAdult' // 用于标记成人内容的属性名
};
// 隐藏内置黄色采集站API的变量
const HIDE_BUILTIN_ADULT_APIS = false;
================================================
FILE: js/customer_site.js
================================================
const CUSTOMER_SITES = {
qiqi: {
api: 'https://www.qiqidys.com/api.php/provide/vod',
name: '七七资源',
}
};
// 调用全局方法合并
if (window.extendAPISites) {
window.extendAPISites(CUSTOMER_SITES);
} else {
console.error("错误:请先加载 config.js!");
}
================================================
FILE: js/douban.js
================================================
// 豆瓣热门电影电视剧推荐功能
// 豆瓣标签列表 - 修改为默认标签
let defaultMovieTags = ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '日综', '爱情', '科幻', '悬疑', '恐怖', '治愈'];
let defaultTvTags = ['热门', '美剧', '英剧', '韩剧', '日剧', '国产剧', '港剧', '日本动画', '综艺', '纪录片'];
// 用户标签列表 - 存储用户实际使用的标签(包含保留的系统标签和用户添加的自定义标签)
let movieTags = [];
let tvTags = [];
// 加载用户标签
function loadUserTags() {
try {
// 尝试从本地存储加载用户保存的标签
const savedMovieTags = localStorage.getItem('userMovieTags');
const savedTvTags = localStorage.getItem('userTvTags');
// 如果本地存储中有标签数据,则使用它
if (savedMovieTags) {
movieTags = JSON.parse(savedMovieTags);
} else {
// 否则使用默认标签
movieTags = [...defaultMovieTags];
}
if (savedTvTags) {
tvTags = JSON.parse(savedTvTags);
} else {
// 否则使用默认标签
tvTags = [...defaultTvTags];
}
} catch (e) {
console.error('加载标签失败:', e);
// 初始化为默认值,防止错误
movieTags = [...defaultMovieTags];
tvTags = [...defaultTvTags];
}
}
// 保存用户标签
function saveUserTags() {
try {
localStorage.setItem('userMovieTags', JSON.stringify(movieTags));
localStorage.setItem('userTvTags', JSON.stringify(tvTags));
} catch (e) {
console.error('保存标签失败:', e);
showToast('保存标签失败', 'error');
}
}
let doubanMovieTvCurrentSwitch = 'movie';
let doubanCurrentTag = '热门';
let doubanPageStart = 0;
const doubanPageSize = 16; // 一次显示的项目数量
// 初始化豆瓣功能
function initDouban() {
// 设置豆瓣开关的初始状态
const doubanToggle = document.getElementById('doubanToggle');
if (doubanToggle) {
const isEnabled = localStorage.getItem('doubanEnabled') === 'true';
doubanToggle.checked = isEnabled;
// 设置开关外观
const toggleBg = doubanToggle.nextElementSibling;
const toggleDot = toggleBg.nextElementSibling;
if (isEnabled) {
toggleBg.classList.add('bg-pink-600');
toggleDot.classList.add('translate-x-6');
}
// 添加事件监听
doubanToggle.addEventListener('change', function(e) {
const isChecked = e.target.checked;
localStorage.setItem('doubanEnabled', isChecked);
// 更新开关外观
if (isChecked) {
toggleBg.classList.add('bg-pink-600');
toggleDot.classList.add('translate-x-6');
} else {
toggleBg.classList.remove('bg-pink-600');
toggleDot.classList.remove('translate-x-6');
}
// 更新显示状态
updateDoubanVisibility();
});
// 初始更新显示状态
updateDoubanVisibility();
// 滚动到页面顶部
window.scrollTo(0, 0);
}
// 加载用户标签
loadUserTags();
// 渲染电影/电视剧切换
renderDoubanMovieTvSwitch();
// 渲染豆瓣标签
renderDoubanTags();
// 换一批按钮事件监听
setupDoubanRefreshBtn();
// 初始加载热门内容
if (localStorage.getItem('doubanEnabled') === 'true') {
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
}
}
// 根据设置更新豆瓣区域的显示状态
function updateDoubanVisibility() {
const doubanArea = document.getElementById('doubanArea');
if (!doubanArea) return;
const isEnabled = localStorage.getItem('doubanEnabled') === 'true';
const isSearching = document.getElementById('resultsArea') &&
!document.getElementById('resultsArea').classList.contains('hidden');
// 只有在启用且没有搜索结果显示时才显示豆瓣区域
if (isEnabled && !isSearching) {
doubanArea.classList.remove('hidden');
// 如果豆瓣结果为空,重新加载
if (document.getElementById('douban-results').children.length === 0) {
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
}
} else {
doubanArea.classList.add('hidden');
}
}
// 只填充搜索框,不执行搜索,让用户自主决定搜索时机
function fillSearchInput(title) {
if (!title) return;
// 安全处理标题,防止XSS
const safeTitle = title
.replace(//g, '>')
.replace(/"/g, '"');
const input = document.getElementById('searchInput');
if (input) {
input.value = safeTitle;
// 聚焦搜索框,便于用户立即使用键盘操作
input.focus();
// 显示一个提示,告知用户点击搜索按钮进行搜索
showToast('已填充搜索内容,点击搜索按钮开始搜索', 'info');
}
}
// 填充搜索框并执行搜索
function fillAndSearch(title) {
if (!title) return;
// 安全处理标题,防止XSS
const safeTitle = title
.replace(//g, '>')
.replace(/"/g, '"');
const input = document.getElementById('searchInput');
if (input) {
input.value = safeTitle;
search(); // 使用已有的search函数执行搜索
// 同时更新浏览器URL,使其反映当前的搜索状态
try {
// 使用URI编码确保特殊字符能够正确显示
const encodedQuery = encodeURIComponent(safeTitle);
// 使用HTML5 History API更新URL,不刷新页面
window.history.pushState(
{ search: safeTitle },
`搜索: ${safeTitle} - LibreTV`,
`/s=${encodedQuery}`
);
// 更新页面标题
document.title = `搜索: ${safeTitle} - LibreTV`;
} catch (e) {
console.error('更新浏览器历史失败:', e);
}
}
}
// 填充搜索框,确保豆瓣资源API被选中,然后执行搜索
async function fillAndSearchWithDouban(title) {
if (!title) return;
// 安全处理标题,防止XSS
const safeTitle = title
.replace(//g, '>')
.replace(/"/g, '"');
// 确保豆瓣资源API被选中
if (typeof selectedAPIs !== 'undefined' && !selectedAPIs.includes('dbzy')) {
// 在设置中勾选豆瓣资源API复选框
const doubanCheckbox = document.querySelector('input[id="api_dbzy"]');
if (doubanCheckbox) {
doubanCheckbox.checked = true;
// 触发updateSelectedAPIs函数以更新状态
if (typeof updateSelectedAPIs === 'function') {
updateSelectedAPIs();
} else {
// 如果函数不可用,则手动添加到selectedAPIs
selectedAPIs.push('dbzy');
localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
// 更新选中API计数(如果有这个元素)
const countEl = document.getElementById('selectedAPICount');
if (countEl) {
countEl.textContent = selectedAPIs.length;
}
}
showToast('已自动选择豆瓣资源API', 'info');
}
}
// 填充搜索框并执行搜索
const input = document.getElementById('searchInput');
if (input) {
input.value = safeTitle;
await search(); // 使用已有的search函数执行搜索
// 更新浏览器URL,使其反映当前的搜索状态
try {
// 使用URI编码确保特殊字符能够正确显示
const encodedQuery = encodeURIComponent(safeTitle);
// 使用HTML5 History API更新URL,不刷新页面
window.history.pushState(
{ search: safeTitle },
`搜索: ${safeTitle} - LibreTV`,
`/s=${encodedQuery}`
);
// 更新页面标题
document.title = `搜索: ${safeTitle} - LibreTV`;
} catch (e) {
console.error('更新浏览器历史失败:', e);
}
if (window.innerWidth <= 768) {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
}
// 渲染电影/电视剧切换器
function renderDoubanMovieTvSwitch() {
// 获取切换按钮元素
const movieToggle = document.getElementById('douban-movie-toggle');
const tvToggle = document.getElementById('douban-tv-toggle');
if (!movieToggle ||!tvToggle) return;
movieToggle.addEventListener('click', function() {
if (doubanMovieTvCurrentSwitch !== 'movie') {
// 更新按钮样式
movieToggle.classList.add('bg-pink-600', 'text-white');
movieToggle.classList.remove('text-gray-300');
tvToggle.classList.remove('bg-pink-600', 'text-white');
tvToggle.classList.add('text-gray-300');
doubanMovieTvCurrentSwitch = 'movie';
doubanCurrentTag = '热门';
// 重新加载豆瓣内容
renderDoubanTags(movieTags);
// 换一批按钮事件监听
setupDoubanRefreshBtn();
// 初始加载热门内容
if (localStorage.getItem('doubanEnabled') === 'true') {
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
}
}
});
// 电视剧按钮点击事件
tvToggle.addEventListener('click', function() {
if (doubanMovieTvCurrentSwitch !== 'tv') {
// 更新按钮样式
tvToggle.classList.add('bg-pink-600', 'text-white');
tvToggle.classList.remove('text-gray-300');
movieToggle.classList.remove('bg-pink-600', 'text-white');
movieToggle.classList.add('text-gray-300');
doubanMovieTvCurrentSwitch = 'tv';
doubanCurrentTag = '热门';
// 重新加载豆瓣内容
renderDoubanTags(tvTags);
// 换一批按钮事件监听
setupDoubanRefreshBtn();
// 初始加载热门内容
if (localStorage.getItem('doubanEnabled') === 'true') {
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
}
}
});
}
// 渲染豆瓣标签选择器
function renderDoubanTags(tags) {
const tagContainer = document.getElementById('douban-tags');
if (!tagContainer) return;
// 确定当前应该使用的标签列表
const currentTags = doubanMovieTvCurrentSwitch === 'movie' ? movieTags : tvTags;
// 清空标签容器
tagContainer.innerHTML = '';
// 先添加标签管理按钮
const manageBtn = document.createElement('button');
manageBtn.className = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white border border-[#333] hover:border-white';
manageBtn.innerHTML = ' 管理标签 ';
manageBtn.onclick = function() {
showTagManageModal();
};
tagContainer.appendChild(manageBtn);
// 添加所有标签
currentTags.forEach(tag => {
const btn = document.createElement('button');
// 设置样式
let btnClass = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 border ';
// 当前选中的标签使用高亮样式
if (tag === doubanCurrentTag) {
btnClass += 'bg-pink-600 text-white shadow-md border-white';
} else {
btnClass += 'bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white border-[#333] hover:border-white';
}
btn.className = btnClass;
btn.textContent = tag;
btn.onclick = function() {
if (doubanCurrentTag !== tag) {
doubanCurrentTag = tag;
doubanPageStart = 0;
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
renderDoubanTags();
}
};
tagContainer.appendChild(btn);
});
}
// 设置换一批按钮事件
function setupDoubanRefreshBtn() {
// 修复ID,使用正确的ID douban-refresh 而不是 douban-refresh-btn
const btn = document.getElementById('douban-refresh');
if (!btn) return;
btn.onclick = function() {
doubanPageStart += doubanPageSize;
if (doubanPageStart > 9 * doubanPageSize) {
doubanPageStart = 0;
}
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
};
}
function fetchDoubanTags() {
const movieTagsTarget = `https://movie.douban.com/j/search_tags?type=movie`
fetchDoubanData(movieTagsTarget)
.then(data => {
movieTags = data.tags;
if (doubanMovieTvCurrentSwitch === 'movie') {
renderDoubanTags(movieTags);
}
})
.catch(error => {
console.error("获取豆瓣热门电影标签失败:", error);
});
const tvTagsTarget = `https://movie.douban.com/j/search_tags?type=tv`
fetchDoubanData(tvTagsTarget)
.then(data => {
tvTags = data.tags;
if (doubanMovieTvCurrentSwitch === 'tv') {
renderDoubanTags(tvTags);
}
})
.catch(error => {
console.error("获取豆瓣热门电视剧标签失败:", error);
});
}
// 渲染热门推荐内容
function renderRecommend(tag, pageLimit, pageStart) {
const container = document.getElementById("douban-results");
if (!container) return;
const loadingOverlayHTML = `
`;
container.classList.add("relative");
container.insertAdjacentHTML('beforeend', loadingOverlayHTML);
const target = `https://movie.douban.com/j/search_subjects?type=${doubanMovieTvCurrentSwitch}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
// 使用通用请求函数
fetchDoubanData(target)
.then(data => {
renderDoubanCards(data, container);
})
.catch(error => {
console.error("获取豆瓣数据失败:", error);
container.innerHTML = `
❌ 获取豆瓣数据失败,请稍后重试
提示:使用VPN可能有助于解决此问题
`;
});
}
async function fetchDoubanData(url) {
// 添加超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 设置请求选项,包括信号和头部
const fetchOptions = {
signal: controller.signal,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Referer': 'https://movie.douban.com/',
'Accept': 'application/json, text/plain, */*',
}
};
try {
// 添加鉴权参数到代理URL
const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ?
await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(url)) :
PROXY_URL + encodeURIComponent(url);
// 尝试直接访问(豆瓣API可能允许部分CORS请求)
const response = await fetch(proxiedUrl, fetchOptions);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (err) {
console.error("豆瓣 API 请求失败(直接代理):", err);
// 失败后尝试备用方法:作为备选
const fallbackUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;
try {
const fallbackResponse = await fetch(fallbackUrl);
if (!fallbackResponse.ok) {
throw new Error(`备用API请求失败! 状态: ${fallbackResponse.status}`);
}
const data = await fallbackResponse.json();
// 解析原始内容
if (data && data.contents) {
return JSON.parse(data.contents);
} else {
throw new Error("无法获取有效数据");
}
} catch (fallbackErr) {
console.error("豆瓣 API 备用请求也失败:", fallbackErr);
throw fallbackErr; // 向上抛出错误,让调用者处理
}
}
}
// 抽取渲染豆瓣卡片的逻辑到单独函数
function renderDoubanCards(data, container) {
// 创建文档片段以提高性能
const fragment = document.createDocumentFragment();
// 如果没有数据
if (!data.subjects || data.subjects.length === 0) {
const emptyEl = document.createElement("div");
emptyEl.className = "col-span-full text-center py-8";
emptyEl.innerHTML = `
❌ 暂无数据,请尝试其他分类或刷新
`;
fragment.appendChild(emptyEl);
} else {
// 循环创建每个影视卡片
data.subjects.forEach(item => {
const card = document.createElement("div");
card.className = "bg-[#111] hover:bg-[#222] transition-all duration-300 rounded-lg overflow-hidden flex flex-col transform hover:scale-105 shadow-md hover:shadow-lg";
// 生成卡片内容,确保安全显示(防止XSS)
const safeTitle = item.title
.replace(//g, '>')
.replace(/"/g, '"');
const safeRate = (item.rate || "暂无")
.replace(//g, '>');
// 处理图片URL
// 1. 直接使用豆瓣图片URL (添加no-referrer属性)
const originalCoverUrl = item.cover;
// 2. 也准备代理URL作为备选
const proxiedCoverUrl = PROXY_URL + encodeURIComponent(originalCoverUrl);
// 为不同设备优化卡片布局
card.innerHTML = `
★ ${safeRate}
${safeTitle}
`;
fragment.appendChild(card);
});
}
// 清空并添加所有新元素
container.innerHTML = "";
container.appendChild(fragment);
}
// 重置到首页
function resetToHome() {
resetSearchArea();
updateDoubanVisibility();
}
// 加载豆瓣首页内容
document.addEventListener('DOMContentLoaded', initDouban);
// 显示标签管理模态框
function showTagManageModal() {
// 确保模态框在页面上只有一个实例
let modal = document.getElementById('tagManageModal');
if (modal) {
document.body.removeChild(modal);
}
// 创建模态框元素
modal = document.createElement('div');
modal.id = 'tagManageModal';
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';
// 当前使用的标签类型和默认标签
const isMovie = doubanMovieTvCurrentSwitch === 'movie';
const currentTags = isMovie ? movieTags : tvTags;
const defaultTags = isMovie ? defaultMovieTags : defaultTvTags;
// 模态框内容
modal.innerHTML = `
×
标签管理 (${isMovie ? '电影' : '电视剧'})
添加新标签
提示:标签名称不能为空,不能重复,不能包含特殊字符
`;
// 添加模态框到页面
document.body.appendChild(modal);
// 焦点放在输入框上
setTimeout(() => {
document.getElementById('newTagInput').focus();
}, 100);
// 添加事件监听器 - 关闭按钮
document.getElementById('closeTagModal').addEventListener('click', function() {
document.body.removeChild(modal);
});
// 添加事件监听器 - 点击模态框外部关闭
modal.addEventListener('click', function(e) {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
// 添加事件监听器 - 恢复默认标签按钮
document.getElementById('resetTagsBtn').addEventListener('click', function() {
resetTagsToDefault();
showTagManageModal(); // 重新加载模态框
});
// 添加事件监听器 - 删除标签按钮
const deleteButtons = document.querySelectorAll('.delete-tag-btn');
deleteButtons.forEach(btn => {
btn.addEventListener('click', function() {
const tagToDelete = this.getAttribute('data-tag');
deleteTag(tagToDelete);
showTagManageModal(); // 重新加载模态框
});
});
// 添加事件监听器 - 表单提交
document.getElementById('addTagForm').addEventListener('submit', function(e) {
e.preventDefault();
const input = document.getElementById('newTagInput');
const newTag = input.value.trim();
if (newTag) {
addTag(newTag);
input.value = '';
showTagManageModal(); // 重新加载模态框
}
});
}
// 添加标签
function addTag(tag) {
// 安全处理标签名,防止XSS
const safeTag = tag
.replace(//g, '>')
.replace(/"/g, '"');
// 确定当前使用的是电影还是电视剧标签
const isMovie = doubanMovieTvCurrentSwitch === 'movie';
const currentTags = isMovie ? movieTags : tvTags;
// 检查是否已存在(忽略大小写)
const exists = currentTags.some(
existingTag => existingTag.toLowerCase() === safeTag.toLowerCase()
);
if (exists) {
showToast('标签已存在', 'warning');
return;
}
// 添加到对应的标签数组
if (isMovie) {
movieTags.push(safeTag);
} else {
tvTags.push(safeTag);
}
// 保存到本地存储
saveUserTags();
// 重新渲染标签
renderDoubanTags();
showToast('标签添加成功', 'success');
}
// 删除标签
function deleteTag(tag) {
// 热门标签不能删除
if (tag === '热门') {
showToast('热门标签不能删除', 'warning');
return;
}
// 确定当前使用的是电影还是电视剧标签
const isMovie = doubanMovieTvCurrentSwitch === 'movie';
const currentTags = isMovie ? movieTags : tvTags;
// 寻找标签索引
const index = currentTags.indexOf(tag);
// 如果找到标签,则删除
if (index !== -1) {
currentTags.splice(index, 1);
// 保存到本地存储
saveUserTags();
// 如果当前选中的是被删除的标签,则重置为"热门"
if (doubanCurrentTag === tag) {
doubanCurrentTag = '热门';
doubanPageStart = 0;
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
}
// 重新渲染标签
renderDoubanTags();
showToast('标签删除成功', 'success');
}
}
// 重置为默认标签
function resetTagsToDefault() {
// 确定当前使用的是电影还是电视剧
const isMovie = doubanMovieTvCurrentSwitch === 'movie';
// 重置为默认标签
if (isMovie) {
movieTags = [...defaultMovieTags];
} else {
tvTags = [...defaultTvTags];
}
// 设置当前标签为热门
doubanCurrentTag = '热门';
doubanPageStart = 0;
// 保存到本地存储
saveUserTags();
// 重新渲染标签和内容
renderDoubanTags();
renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
showToast('已恢复默认标签', 'success');
}
================================================
FILE: js/index-page.js
================================================
// 页面加载后显示弹窗脚本
document.addEventListener('DOMContentLoaded', function() {
// 弹窗显示脚本
// 检查用户是否已经看过声明
const hasSeenDisclaimer = localStorage.getItem('hasSeenDisclaimer');
if (!hasSeenDisclaimer) {
// 显示弹窗
const disclaimerModal = document.getElementById('disclaimerModal');
disclaimerModal.style.display = 'flex';
// 添加接受按钮事件
document.getElementById('acceptDisclaimerBtn').addEventListener('click', function() {
// 保存用户已看过声明的状态
localStorage.setItem('hasSeenDisclaimer', 'true');
// 隐藏弹窗
disclaimerModal.style.display = 'none';
});
}
// URL搜索参数处理脚本
// 首先检查是否是播放URL格式 (/watch 开头的路径)
if (window.location.pathname.startsWith('/watch')) {
// 播放URL,不做额外处理,watch.html会处理重定向
return;
}
// 检查页面路径中的搜索参数 (格式: /s=keyword)
const path = window.location.pathname;
const searchPrefix = '/s=';
if (path.startsWith(searchPrefix)) {
// 提取搜索关键词
const keyword = decodeURIComponent(path.substring(searchPrefix.length));
if (keyword) {
// 设置搜索框的值
document.getElementById('searchInput').value = keyword;
// 显示清空按钮
toggleClearButton();
// 执行搜索
setTimeout(() => {
// 使用setTimeout确保其他DOM加载和初始化完成
search();
// 更新浏览器历史,不改变URL (保持搜索参数在地址栏)
try {
window.history.replaceState(
{ search: keyword },
`搜索: ${keyword} - LibreTV`,
window.location.href
);
} catch (e) {
console.error('更新浏览器历史失败:', e);
}
}, 300);
}
}
// 也检查查询字符串中的搜索参数 (格式: ?s=keyword)
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get('s');
if (searchQuery) {
// 设置搜索框的值
document.getElementById('searchInput').value = searchQuery;
// 执行搜索
setTimeout(() => {
search();
// 更新URL为规范格式
try {
window.history.replaceState(
{ search: searchQuery },
`搜索: ${searchQuery} - LibreTV`,
`/s=${encodeURIComponent(searchQuery)}`
);
} catch (e) {
console.error('更新浏览器历史失败:', e);
}
}, 300);
}
});
================================================
FILE: js/password.js
================================================
// 密码保护功能
/**
* 检查是否设置了密码保护
* 通过读取页面上嵌入的环境变量来检查
*/
function isPasswordProtected() {
// 只检查普通密码
const pwd = window.__ENV__ && window.__ENV__.PASSWORD;
// 检查普通密码是否有效
return typeof pwd === 'string' && pwd.length === 64 && !/^0+$/.test(pwd);
}
/**
* 检查是否强制要求设置密码
* 如果没有设置有效的 PASSWORD,则认为需要强制设置密码
* 为了安全考虑,所有部署都必须设置密码
*/
function isPasswordRequired() {
return !isPasswordProtected();
}
/**
* 强制密码保护检查 - 防止绕过
* 在关键操作前都应该调用此函数
*/
function ensurePasswordProtection() {
if (isPasswordRequired()) {
showPasswordModal();
throw new Error('Password protection is required');
}
if (isPasswordProtected() && !isPasswordVerified()) {
showPasswordModal();
throw new Error('Password verification required');
}
return true;
}
window.isPasswordProtected = isPasswordProtected;
window.isPasswordRequired = isPasswordRequired;
/**
* 验证用户输入的密码是否正确(异步,使用SHA-256哈希)
*/
async function verifyPassword(password) {
try {
const correctHash = window.__ENV__?.PASSWORD;
if (!correctHash) return false;
const inputHash = await sha256(password);
const isValid = inputHash === correctHash;
if (isValid) {
localStorage.setItem(PASSWORD_CONFIG.localStorageKey, JSON.stringify({
verified: true,
timestamp: Date.now(),
passwordHash: correctHash
}));
}
return isValid;
} catch (error) {
console.error('验证密码时出错:', error);
return false;
}
}
// 验证状态检查
function isPasswordVerified() {
try {
if (!isPasswordProtected()) return true;
const stored = localStorage.getItem(PASSWORD_CONFIG.localStorageKey);
if (!stored) return false;
const { timestamp, passwordHash } = JSON.parse(stored);
const currentHash = window.__ENV__?.PASSWORD;
return timestamp && passwordHash === currentHash &&
Date.now() - timestamp < PASSWORD_CONFIG.verificationTTL;
} catch (error) {
console.error('检查密码验证状态时出错:', error);
return false;
}
}
// 更新全局导出
window.isPasswordProtected = isPasswordProtected;
window.isPasswordRequired = isPasswordRequired;
window.isPasswordVerified = isPasswordVerified;
window.verifyPassword = verifyPassword;
window.ensurePasswordProtection = ensurePasswordProtection;
// SHA-256实现,可用Web Crypto API
async function sha256(message) {
if (window.crypto && crypto.subtle && crypto.subtle.digest) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// HTTP 下调用原始 js‑sha256
if (typeof window._jsSha256 === 'function') {
return window._jsSha256(message);
}
throw new Error('No SHA-256 implementation available.');
}
/**
* 显示密码验证弹窗
*/
function showPasswordModal() {
const passwordModal = document.getElementById('passwordModal');
if (passwordModal) {
// 防止出现豆瓣区域滚动条
document.getElementById('doubanArea').classList.add('hidden');
document.getElementById('passwordCancelBtn').classList.add('hidden');
// 检查是否需要强制设置密码
if (isPasswordRequired()) {
// 修改弹窗内容提示用户需要先设置密码
const title = passwordModal.querySelector('h2');
const description = passwordModal.querySelector('p');
if (title) title.textContent = '需要设置密码';
if (description) description.textContent = '请先在部署平台设置 PASSWORD 环境变量来保护您的实例';
// 隐藏密码输入框和提交按钮,只显示提示信息
const form = passwordModal.querySelector('form');
const errorMsg = document.getElementById('passwordError');
if (form) form.style.display = 'none';
if (errorMsg) {
errorMsg.textContent = '为确保安全,必须设置 PASSWORD 环境变量才能使用本服务,请联系管理员进行配置';
errorMsg.classList.remove('hidden');
errorMsg.className = 'text-red-500 mt-2 font-medium'; // 改为更醒目的红色
}
} else {
// 正常的密码验证模式
const title = passwordModal.querySelector('h2');
const description = passwordModal.querySelector('p');
if (title) title.textContent = '访问验证';
if (description) description.textContent = '请输入密码继续访问';
const form = passwordModal.querySelector('form');
if (form) form.style.display = 'block';
}
passwordModal.style.display = 'flex';
// 只有在非强制设置密码模式下才聚焦输入框
if (!isPasswordRequired()) {
// 确保输入框获取焦点
setTimeout(() => {
const passwordInput = document.getElementById('passwordInput');
if (passwordInput) {
passwordInput.focus();
}
}, 100);
}
}
}
/**
* 隐藏密码验证弹窗
*/
function hidePasswordModal() {
const passwordModal = document.getElementById('passwordModal');
if (passwordModal) {
// 隐藏密码错误提示
hidePasswordError();
// 清空密码输入框
const passwordInput = document.getElementById('passwordInput');
if (passwordInput) passwordInput.value = '';
passwordModal.style.display = 'none';
// 如果启用豆瓣区域则显示豆瓣区域
if (localStorage.getItem('doubanEnabled') === 'true') {
document.getElementById('doubanArea').classList.remove('hidden');
initDouban();
}
}
}
/**
* 显示密码错误信息
*/
function showPasswordError() {
const errorElement = document.getElementById('passwordError');
if (errorElement) {
errorElement.classList.remove('hidden');
}
}
/**
* 隐藏密码错误信息
*/
function hidePasswordError() {
const errorElement = document.getElementById('passwordError');
if (errorElement) {
errorElement.classList.add('hidden');
}
}
/**
* 处理密码提交事件(异步)
*/
async function handlePasswordSubmit() {
const passwordInput = document.getElementById('passwordInput');
const password = passwordInput ? passwordInput.value.trim() : '';
if (await verifyPassword(password)) {
hidePasswordModal();
// 触发密码验证成功事件
document.dispatchEvent(new CustomEvent('passwordVerified'));
} else {
showPasswordError();
if (passwordInput) {
passwordInput.value = '';
passwordInput.focus();
}
}
}
/**
* 初始化密码验证系统
*/
function initPasswordProtection() {
// 如果需要强制设置密码,显示警告弹窗
if (isPasswordRequired()) {
showPasswordModal();
return;
}
// 如果设置了密码但用户未验证,显示密码输入框
if (isPasswordProtected() && !isPasswordVerified()) {
showPasswordModal();
return;
}
}
// 在页面加载完成后初始化密码保护
document.addEventListener('DOMContentLoaded', function () {
initPasswordProtection();
});
================================================
FILE: js/player.js
================================================
const selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '[]');
const customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); // 存储自定义API列表
// 改进返回功能
function goBack(event) {
// 防止默认链接行为
if (event) event.preventDefault();
// 1. 优先检查URL参数中的returnUrl
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl');
if (returnUrl) {
// 如果URL中有returnUrl参数,优先使用
window.location.href = decodeURIComponent(returnUrl);
return;
}
// 2. 检查localStorage中保存的lastPageUrl
const lastPageUrl = localStorage.getItem('lastPageUrl');
if (lastPageUrl && lastPageUrl !== window.location.href) {
window.location.href = lastPageUrl;
return;
}
// 3. 检查是否是从搜索页面进入的播放器
const referrer = document.referrer;
// 检查 referrer 是否包含搜索参数
if (referrer && (referrer.includes('/s=') || referrer.includes('?s='))) {
// 如果是从搜索页面来的,返回到搜索页面
window.location.href = referrer;
return;
}
// 4. 如果是在iframe中打开的,尝试关闭iframe
if (window.self !== window.top) {
try {
// 尝试调用父窗口的关闭播放器函数
window.parent.closeVideoPlayer && window.parent.closeVideoPlayer();
return;
} catch (e) {
console.error('调用父窗口closeVideoPlayer失败:', e);
}
}
// 5. 无法确定上一页,则返回首页
if (!referrer || referrer === '') {
window.location.href = '/';
return;
}
// 6. 以上都不满足,使用默认行为:返回上一页
window.history.back();
}
// 页面加载时保存当前URL到localStorage,作为返回目标
window.addEventListener('load', function () {
// 保存前一页面URL
if (document.referrer && document.referrer !== window.location.href) {
localStorage.setItem('lastPageUrl', document.referrer);
}
// 提取当前URL中的重要参数,以便在需要时能够恢复当前页面
const urlParams = new URLSearchParams(window.location.search);
const videoId = urlParams.get('id');
const sourceCode = urlParams.get('source');
if (videoId && sourceCode) {
// 保存当前播放状态,以便其他页面可以返回
localStorage.setItem('currentPlayingId', videoId);
localStorage.setItem('currentPlayingSource', sourceCode);
}
});
// =================================
// ============== PLAYER ==========
// =================================
// 全局变量
let currentVideoTitle = '';
let currentEpisodeIndex = 0;
let art = null; // 用于 ArtPlayer 实例
let currentHls = null; // 跟踪当前HLS实例
let currentEpisodes = [];
let episodesReversed = false;
let autoplayEnabled = true; // 默认开启自动连播
let videoHasEnded = false; // 跟踪视频是否已经自然结束
let userClickedPosition = null; // 记录用户点击的位置
let shortcutHintTimeout = null; // 用于控制快捷键提示显示时间
let adFilteringEnabled = true; // 默认开启广告过滤
let progressSaveInterval = null; // 定期保存进度的计时器
let currentVideoUrl = ''; // 记录当前实际的视频URL
const isWebkit = (typeof window.webkitConvertPointFromNodeToPage === 'function')
Artplayer.FULLSCREEN_WEB_IN_BODY = true;
// 页面加载
document.addEventListener('DOMContentLoaded', function () {
// 先检查用户是否已通过密码验证
if (!isPasswordVerified()) {
// 隐藏加载提示
document.getElementById('player-loading').style.display = 'none';
return;
}
initializePageContent();
});
// 监听密码验证成功事件
document.addEventListener('passwordVerified', () => {
document.getElementById('player-loading').style.display = 'block';
initializePageContent();
});
// 初始化页面内容
function initializePageContent() {
// 解析URL参数
const urlParams = new URLSearchParams(window.location.search);
let videoUrl = urlParams.get('url');
const title = urlParams.get('title');
const sourceCode = urlParams.get('source');
let index = parseInt(urlParams.get('index') || '0');
const episodesList = urlParams.get('episodes'); // 从URL获取集数信息
const savedPosition = parseInt(urlParams.get('position') || '0'); // 获取保存的播放位置
// 解决历史记录问题:检查URL是否是player.html开头的链接
// 如果是,说明这是历史记录重定向,需要解析真实的视频URL
if (videoUrl && videoUrl.includes('player.html')) {
try {
// 尝试从嵌套URL中提取真实的视频链接
const nestedUrlParams = new URLSearchParams(videoUrl.split('?')[1]);
// 从嵌套参数中获取真实视频URL
const nestedVideoUrl = nestedUrlParams.get('url');
// 检查嵌套URL是否包含播放位置信息
const nestedPosition = nestedUrlParams.get('position');
const nestedIndex = nestedUrlParams.get('index');
const nestedTitle = nestedUrlParams.get('title');
if (nestedVideoUrl) {
videoUrl = nestedVideoUrl;
// 更新当前URL参数
const url = new URL(window.location.href);
if (!urlParams.has('position') && nestedPosition) {
url.searchParams.set('position', nestedPosition);
}
if (!urlParams.has('index') && nestedIndex) {
url.searchParams.set('index', nestedIndex);
}
if (!urlParams.has('title') && nestedTitle) {
url.searchParams.set('title', nestedTitle);
}
// 替换当前URL
window.history.replaceState({}, '', url);
} else {
showError('历史记录链接无效,请返回首页重新访问');
}
} catch (e) {
}
}
// 保存当前视频URL
currentVideoUrl = videoUrl || '';
// 从localStorage获取数据
currentVideoTitle = title || localStorage.getItem('currentVideoTitle') || '未知视频';
currentEpisodeIndex = index;
// 设置自动连播开关状态
autoplayEnabled = localStorage.getItem('autoplayEnabled') !== 'false'; // 默认为true
document.getElementById('autoplayToggle').checked = autoplayEnabled;
// 获取广告过滤设置
adFilteringEnabled = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true
// 监听自动连播开关变化
document.getElementById('autoplayToggle').addEventListener('change', function (e) {
autoplayEnabled = e.target.checked;
localStorage.setItem('autoplayEnabled', autoplayEnabled);
});
// 优先使用URL传递的集数信息,否则从localStorage获取
try {
if (episodesList) {
// 如果URL中有集数数据,优先使用它
currentEpisodes = JSON.parse(decodeURIComponent(episodesList));
} else {
// 否则从localStorage获取
currentEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');
}
// 检查集数索引是否有效,如果无效则调整为0
if (index < 0 || (currentEpisodes.length > 0 && index >= currentEpisodes.length)) {
// 如果索引太大,则使用最大有效索引
if (index >= currentEpisodes.length && currentEpisodes.length > 0) {
index = currentEpisodes.length - 1;
} else {
index = 0;
}
// 更新URL以反映修正后的索引
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('index', index);
window.history.replaceState({}, '', newUrl);
}
// 更新当前索引为验证过的值
currentEpisodeIndex = index;
episodesReversed = localStorage.getItem('episodesReversed') === 'true';
} catch (e) {
currentEpisodes = [];
currentEpisodeIndex = 0;
episodesReversed = false;
}
// 设置页面标题
document.title = currentVideoTitle + ' - LibreTV播放器';
document.getElementById('videoTitle').textContent = currentVideoTitle;
// 初始化播放器
if (videoUrl) {
initPlayer(videoUrl);
} else {
showError('无效的视频链接');
}
// 渲染源信息
renderResourceInfoBar();
// 更新集数信息
updateEpisodeInfo();
// 渲染集数列表
renderEpisodes();
// 更新按钮状态
updateButtonStates();
// 更新排序按钮状态
updateOrderButton();
// 添加对进度条的监听,确保点击准确跳转
setTimeout(() => {
setupProgressBarPreciseClicks();
}, 1000);
// 添加键盘快捷键事件监听
document.addEventListener('keydown', handleKeyboardShortcuts);
// 添加页面离开事件监听,保存播放位置
window.addEventListener('beforeunload', saveCurrentProgress);
// 新增:页面隐藏(切后台/切标签)时也保存
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') {
saveCurrentProgress();
}
});
// 视频暂停时也保存
const waitForVideo = setInterval(() => {
if (art && art.video) {
art.video.addEventListener('pause', saveCurrentProgress);
// 新增:播放进度变化时节流保存
let lastSave = 0;
art.video.addEventListener('timeupdate', function() {
const now = Date.now();
if (now - lastSave > 5000) { // 每5秒最多保存一次
saveCurrentProgress();
lastSave = now;
}
});
clearInterval(waitForVideo);
}
}, 200);
}
// 处理键盘快捷键
function handleKeyboardShortcuts(e) {
// 忽略输入框中的按键事件
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Alt + 左箭头 = 上一集
if (e.altKey && e.key === 'ArrowLeft') {
if (currentEpisodeIndex > 0) {
playPreviousEpisode();
showShortcutHint('上一集', 'left');
e.preventDefault();
}
}
// Alt + 右箭头 = 下一集
if (e.altKey && e.key === 'ArrowRight') {
if (currentEpisodeIndex < currentEpisodes.length - 1) {
playNextEpisode();
showShortcutHint('下一集', 'right');
e.preventDefault();
}
}
// 左箭头 = 快退
if (!e.altKey && e.key === 'ArrowLeft') {
if (art && art.currentTime > 5) {
art.currentTime -= 5;
showShortcutHint('快退', 'left');
e.preventDefault();
}
}
// 右箭头 = 快进
if (!e.altKey && e.key === 'ArrowRight') {
if (art && art.currentTime < art.duration - 5) {
art.currentTime += 5;
showShortcutHint('快进', 'right');
e.preventDefault();
}
}
// 上箭头 = 音量+
if (e.key === 'ArrowUp') {
if (art && art.volume < 1) {
art.volume += 0.1;
showShortcutHint('音量+', 'up');
e.preventDefault();
}
}
// 下箭头 = 音量-
if (e.key === 'ArrowDown') {
if (art && art.volume > 0) {
art.volume -= 0.1;
showShortcutHint('音量-', 'down');
e.preventDefault();
}
}
// 空格 = 播放/暂停
if (e.key === ' ') {
if (art) {
art.toggle();
showShortcutHint('播放/暂停', 'play');
e.preventDefault();
}
}
// f 键 = 切换全屏
if (e.key === 'f' || e.key === 'F') {
if (art) {
art.fullscreen = !art.fullscreen;
showShortcutHint('切换全屏', 'fullscreen');
e.preventDefault();
}
}
}
// 显示快捷键提示
function showShortcutHint(text, direction) {
const hintElement = document.getElementById('shortcutHint');
const textElement = document.getElementById('shortcutText');
const iconElement = document.getElementById('shortcutIcon');
// 清除之前的超时
if (shortcutHintTimeout) {
clearTimeout(shortcutHintTimeout);
}
// 设置文本和图标方向
textElement.textContent = text;
if (direction === 'left') {
iconElement.innerHTML = ' ';
} else if (direction === 'right') {
iconElement.innerHTML = ' ';
} else if (direction === 'up') {
iconElement.innerHTML = ' ';
} else if (direction === 'down') {
iconElement.innerHTML = ' ';
} else if (direction === 'fullscreen') {
iconElement.innerHTML = ' ';
} else if (direction === 'play') {
iconElement.innerHTML = ' ';
}
// 显示提示
hintElement.classList.add('show');
// 两秒后隐藏
shortcutHintTimeout = setTimeout(() => {
hintElement.classList.remove('show');
}, 2000);
}
// 初始化播放器
function initPlayer(videoUrl) {
if (!videoUrl) {
return
}
// 销毁旧实例
if (art) {
art.destroy();
art = null;
}
// 配置HLS.js选项
const hlsConfig = {
debug: false,
loader: adFilteringEnabled ? CustomHlsJsLoader : Hls.DefaultConfig.loader,
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxBufferSize: 30 * 1000 * 1000,
maxBufferHole: 0.5,
fragLoadingMaxRetry: 6,
fragLoadingMaxRetryTimeout: 64000,
fragLoadingRetryDelay: 1000,
manifestLoadingMaxRetry: 3,
manifestLoadingRetryDelay: 1000,
levelLoadingMaxRetry: 4,
levelLoadingRetryDelay: 1000,
startLevel: -1,
abrEwmaDefaultEstimate: 500000,
abrBandWidthFactor: 0.95,
abrBandWidthUpFactor: 0.7,
abrMaxWithRealBitrate: true,
stretchShortVideoTrack: true,
appendErrorMaxRetry: 5, // 增加尝试次数
liveSyncDurationCount: 3,
liveDurationInfinity: false
};
// Create new ArtPlayer instance
art = new Artplayer({
container: '#player',
url: videoUrl,
type: 'm3u8',
title: videoTitle,
volume: 0.8,
isLive: false,
muted: false,
autoplay: true,
pip: true,
autoSize: false,
autoMini: true,
screenshot: true,
setting: true,
loop: false,
flip: false,
playbackRate: true,
aspectRatio: false,
fullscreen: true,
fullscreenWeb: true,
subtitleOffset: false,
miniProgressBar: true,
mutex: true,
backdrop: true,
playsInline: true,
autoPlayback: false,
airplay: true,
hotkey: false,
theme: '#23ade5',
lang: navigator.language.toLowerCase(),
moreVideoAttr: {
crossOrigin: 'anonymous',
},
customType: {
m3u8: function (video, url) {
// 清理之前的HLS实例
if (currentHls && currentHls.destroy) {
try {
currentHls.destroy();
} catch (e) {
}
}
// 创建新的HLS实例
const hls = new Hls(hlsConfig);
currentHls = hls;
// 跟踪是否已经显示错误
let errorDisplayed = false;
// 跟踪是否有错误发生
let errorCount = 0;
// 跟踪视频是否开始播放
let playbackStarted = false;
// 跟踪视频是否出现bufferAppendError
let bufferAppendErrorCount = 0;
// 监听视频播放事件
video.addEventListener('playing', function () {
playbackStarted = true;
document.getElementById('player-loading').style.display = 'none';
document.getElementById('error').style.display = 'none';
});
// 监听视频进度事件
video.addEventListener('timeupdate', function () {
if (video.currentTime > 1) {
// 视频进度超过1秒,隐藏错误(如果存在)
document.getElementById('error').style.display = 'none';
}
});
hls.loadSource(url);
hls.attachMedia(video);
// enable airplay, from https://github.com/video-dev/hls.js/issues/5989
// 检查是否已存在source元素,如果存在则更新,不存在则创建
let sourceElement = video.querySelector('source');
if (sourceElement) {
// 更新现有source元素的URL
sourceElement.src = videoUrl;
} else {
// 创建新的source元素
sourceElement = document.createElement('source');
sourceElement.src = videoUrl;
video.appendChild(sourceElement);
}
video.disableRemotePlayback = false;
hls.on(Hls.Events.MANIFEST_PARSED, function () {
video.play().catch(e => {
});
});
hls.on(Hls.Events.ERROR, function (event, data) {
// 增加错误计数
errorCount++;
// 处理bufferAppendError
if (data.details === 'bufferAppendError') {
bufferAppendErrorCount++;
// 如果视频已经开始播放,则忽略这个错误
if (playbackStarted) {
return;
}
// 如果出现多次bufferAppendError但视频未播放,尝试恢复
if (bufferAppendErrorCount >= 3) {
hls.recoverMediaError();
}
}
// 如果是致命错误,且视频未播放
if (data.fatal && !playbackStarted) {
// 尝试恢复错误
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
break;
default:
// 仅在多次恢复尝试后显示错误
if (errorCount > 3 && !errorDisplayed) {
errorDisplayed = true;
showError('视频加载失败,可能是格式不兼容或源不可用');
}
break;
}
}
});
// 监听分段加载事件
hls.on(Hls.Events.FRAG_LOADED, function () {
document.getElementById('player-loading').style.display = 'none';
});
// 监听级别加载事件
hls.on(Hls.Events.LEVEL_LOADED, function () {
document.getElementById('player-loading').style.display = 'none';
});
}
}
});
// artplayer 没有 'fullscreenWeb:enter', 'fullscreenWeb:exit' 等事件
// 所以原控制栏隐藏代码并没有起作用
// 实际起作用的是 artplayer 默认行为,它支持自动隐藏工具栏
// 但有一个 bug: 在副屏全屏时,鼠标移出副屏后不会自动隐藏工具栏
// 下面进一并重构和修复:
let hideTimer;
// 隐藏控制栏
function hideControls() {
if (art && art.controls) {
art.controls.show = false;
}
}
// 重置计时器,计时器超时时间与 artplayer 保持一致
function resetHideTimer() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
hideControls();
}, Artplayer.CONTROL_HIDE_TIME);
}
// 处理鼠标离开浏览器窗口
function handleMouseOut(e) {
if (e && !e.relatedTarget) {
resetHideTimer();
}
}
// 全屏状态切换时注册/移除 mouseout 事件,监听鼠标移出屏幕事件
// 从而对播放器状态栏进行隐藏倒计时
function handleFullScreen(isFullScreen, isWeb) {
if (isFullScreen) {
document.addEventListener('mouseout', handleMouseOut);
} else {
document.removeEventListener('mouseout', handleMouseOut);
// 退出全屏时清理计时器
clearTimeout(hideTimer);
}
if (!isWeb) {
if (window.screen.orientation && window.screen.orientation.lock) {
window.screen.orientation.lock('landscape')
.then(() => {
})
.catch((error) => {
});
}
}
}
// 播放器加载完成后初始隐藏工具栏
art.on('ready', () => {
hideControls();
});
// 全屏 Web 模式处理
art.on('fullscreenWeb', function (isFullScreen) {
handleFullScreen(isFullScreen, true);
});
// 全屏模式处理
art.on('fullscreen', function (isFullScreen) {
handleFullScreen(isFullScreen, false);
});
art.on('video:loadedmetadata', function() {
document.getElementById('player-loading').style.display = 'none';
videoHasEnded = false; // 视频加载时重置结束标志
// 优先使用URL传递的position参数
const urlParams = new URLSearchParams(window.location.search);
const savedPosition = parseInt(urlParams.get('position') || '0');
if (savedPosition > 10 && savedPosition < art.duration - 2) {
// 如果URL中有有效的播放位置参数,直接使用它
art.currentTime = savedPosition;
showPositionRestoreHint(savedPosition);
} else {
// 否则尝试从本地存储恢复播放进度
try {
const progressKey = 'videoProgress_' + getVideoId();
const progressStr = localStorage.getItem(progressKey);
if (progressStr && art.duration > 0) {
const progress = JSON.parse(progressStr);
if (
progress &&
typeof progress.position === 'number' &&
progress.position > 10 &&
progress.position < art.duration - 2
) {
art.currentTime = progress.position;
showPositionRestoreHint(progress.position);
}
}
} catch (e) {
}
}
// 设置进度条点击监听
setupProgressBarPreciseClicks();
// 视频加载成功后,在稍微延迟后将其添加到观看历史
setTimeout(saveToHistory, 3000);
// 启动定期保存播放进度
startProgressSaveInterval();
})
// 错误处理
art.on('video:error', function (error) {
// 如果正在切换视频,忽略错误
if (window.isSwitchingVideo) {
return;
}
// 隐藏所有加载指示器
const loadingElements = document.querySelectorAll('#player-loading, .player-loading-container');
loadingElements.forEach(el => {
if (el) el.style.display = 'none';
});
showError('视频播放失败: ' + (error.message || '未知错误'));
});
// 添加移动端长按三倍速播放功能
setupLongPressSpeedControl();
// 视频播放结束事件
art.on('video:ended', function () {
videoHasEnded = true;
clearVideoProgress();
// 如果自动播放下一集开启,且确实有下一集
if (autoplayEnabled && currentEpisodeIndex < currentEpisodes.length - 1) {
// 稍长延迟以确保所有事件处理完成
setTimeout(() => {
// 确认不是因为用户拖拽导致的假结束事件
playNextEpisode();
videoHasEnded = false; // 重置标志
}, 1000);
} else {
art.fullscreen = false;
}
});
// 添加双击全屏支持
art.on('video:playing', () => {
// 绑定双击事件到视频容器
if (art.video) {
art.video.addEventListener('dblclick', () => {
art.fullscreen = !art.fullscreen;
art.play();
});
}
});
// 10秒后如果仍在加载,但不立即显示错误
setTimeout(function () {
// 如果视频已经播放开始,则不显示错误
if (art && art.video && art.video.currentTime > 0) {
return;
}
const loadingElement = document.getElementById('player-loading');
if (loadingElement && loadingElement.style.display !== 'none') {
loadingElement.innerHTML = `
视频加载时间较长,请耐心等待...
如长时间无响应,请尝试其他视频源
`;
}
}, 10000);
}
// 自定义M3U8 Loader用于过滤广告
class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
constructor(config) {
super(config);
const load = this.load.bind(this);
this.load = function (context, config, callbacks) {
// 拦截manifest和level请求
if (context.type === 'manifest' || context.type === 'level') {
const onSuccess = callbacks.onSuccess;
callbacks.onSuccess = function (response, stats, context) {
// 如果是m3u8文件,处理内容以移除广告分段
if (response.data && typeof response.data === 'string') {
// 过滤掉广告段 - 实现更精确的广告过滤逻辑
response.data = filterAdsFromM3U8(response.data, true);
}
return onSuccess(response, stats, context);
};
}
// 执行原始load方法
load(context, config, callbacks);
};
}
}
// 过滤可疑的广告内容
function filterAdsFromM3U8(m3u8Content, strictMode = false) {
if (!m3u8Content) return '';
// 按行分割M3U8内容
const lines = m3u8Content.split('\n');
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 只过滤#EXT-X-DISCONTINUITY标识
if (!line.includes('#EXT-X-DISCONTINUITY')) {
filteredLines.push(line);
}
}
return filteredLines.join('\n');
}
// 显示错误
function showError(message) {
// 在视频已经播放的情况下不显示错误
if (art && art.video && art.video.currentTime > 1) {
return;
}
const loadingEl = document.getElementById('player-loading');
if (loadingEl) loadingEl.style.display = 'none';
const errorEl = document.getElementById('error');
if (errorEl) errorEl.style.display = 'flex';
const errorMsgEl = document.getElementById('error-message');
if (errorMsgEl) errorMsgEl.textContent = message;
}
// 更新集数信息
function updateEpisodeInfo() {
if (currentEpisodes.length > 0) {
document.getElementById('episodeInfo').textContent = `第 ${currentEpisodeIndex + 1}/${currentEpisodes.length} 集`;
} else {
document.getElementById('episodeInfo').textContent = '无集数信息';
}
}
// 更新按钮状态
function updateButtonStates() {
const prevButton = document.getElementById('prevButton');
const nextButton = document.getElementById('nextButton');
// 处理上一集按钮
if (currentEpisodeIndex > 0) {
prevButton.classList.remove('bg-gray-700', 'cursor-not-allowed');
prevButton.classList.add('bg-[#222]', 'hover:bg-[#333]');
prevButton.removeAttribute('disabled');
} else {
prevButton.classList.add('bg-gray-700', 'cursor-not-allowed');
prevButton.classList.remove('bg-[#222]', 'hover:bg-[#333]');
prevButton.setAttribute('disabled', '');
}
// 处理下一集按钮
if (currentEpisodeIndex < currentEpisodes.length - 1) {
nextButton.classList.remove('bg-gray-700', 'cursor-not-allowed');
nextButton.classList.add('bg-[#222]', 'hover:bg-[#333]');
nextButton.removeAttribute('disabled');
} else {
nextButton.classList.add('bg-gray-700', 'cursor-not-allowed');
nextButton.classList.remove('bg-[#222]', 'hover:bg-[#333]');
nextButton.setAttribute('disabled', '');
}
}
// 渲染集数按钮
function renderEpisodes() {
const episodesList = document.getElementById('episodesList');
if (!episodesList) return;
if (!currentEpisodes || currentEpisodes.length === 0) {
episodesList.innerHTML = '没有可用的集数
';
return;
}
const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;
let html = '';
episodes.forEach((episode, index) => {
// 根据倒序状态计算真实的剧集索引
const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index;
const isActive = realIndex === currentEpisodeIndex;
html += `
${realIndex + 1}
`;
});
episodesList.innerHTML = html;
}
// 播放指定集数
function playEpisode(index) {
// 确保index在有效范围内
if (index < 0 || index >= currentEpisodes.length) {
return;
}
// 保存当前播放进度(如果正在播放)
if (art && art.video && !art.video.paused && !videoHasEnded) {
saveCurrentProgress();
}
// 清除进度保存计时器
if (progressSaveInterval) {
clearInterval(progressSaveInterval);
progressSaveInterval = null;
}
// 首先隐藏之前可能显示的错误
document.getElementById('error').style.display = 'none';
// 显示加载指示器
document.getElementById('player-loading').style.display = 'flex';
document.getElementById('player-loading').innerHTML = `
正在加载视频...
`;
// 获取 sourceCode
const urlParams2 = new URLSearchParams(window.location.search);
const sourceCode = urlParams2.get('source_code');
// 准备切换剧集的URL
const url = currentEpisodes[index];
// 更新当前剧集索引
currentEpisodeIndex = index;
currentVideoUrl = url;
videoHasEnded = false; // 重置视频结束标志
clearVideoProgress();
// 更新URL参数(不刷新页面)
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('index', index);
currentUrl.searchParams.set('url', url);
currentUrl.searchParams.delete('position');
window.history.replaceState({}, '', currentUrl.toString());
if (isWebkit) {
initPlayer(url);
} else {
art.switch = url;
}
// 更新UI
updateEpisodeInfo();
updateButtonStates();
renderEpisodes();
// 重置用户点击位置记录
userClickedPosition = null;
// 三秒后保存到历史记录
setTimeout(() => saveToHistory(), 3000);
}
// 播放上一集
function playPreviousEpisode() {
if (currentEpisodeIndex > 0) {
playEpisode(currentEpisodeIndex - 1);
}
}
// 播放下一集
function playNextEpisode() {
if (currentEpisodeIndex < currentEpisodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
}
}
// 复制播放链接
function copyLinks() {
// 尝试从URL中获取参数
const urlParams = new URLSearchParams(window.location.search);
const linkUrl = urlParams.get('url') || '';
if (linkUrl !== '') {
navigator.clipboard.writeText(linkUrl).then(() => {
showToast('播放链接已复制', 'success');
}).catch(err => {
showToast('复制失败,请检查浏览器权限', 'error');
});
}
}
// 切换集数排序
function toggleEpisodeOrder() {
episodesReversed = !episodesReversed;
// 保存到localStorage
localStorage.setItem('episodesReversed', episodesReversed);
// 重新渲染集数列表
renderEpisodes();
// 更新排序按钮
updateOrderButton();
}
// 更新排序按钮状态
function updateOrderButton() {
const orderText = document.getElementById('orderText');
const orderIcon = document.getElementById('orderIcon');
if (orderText && orderIcon) {
orderText.textContent = episodesReversed ? '正序排列' : '倒序排列';
orderIcon.style.transform = episodesReversed ? 'rotate(180deg)' : '';
}
}
// 设置进度条准确点击处理
function setupProgressBarPreciseClicks() {
// 查找DPlayer的进度条元素
const progressBar = document.querySelector('.dplayer-bar-wrap');
if (!progressBar || !art || !art.video) return;
// 移除可能存在的旧事件监听器
progressBar.removeEventListener('mousedown', handleProgressBarClick);
// 添加新的事件监听器
progressBar.addEventListener('mousedown', handleProgressBarClick);
// 在移动端也添加触摸事件支持
progressBar.removeEventListener('touchstart', handleProgressBarTouch);
progressBar.addEventListener('touchstart', handleProgressBarTouch);
// 处理进度条点击
function handleProgressBarClick(e) {
if (!art || !art.video) return;
// 计算点击位置相对于进度条的比例
const rect = e.currentTarget.getBoundingClientRect();
const percentage = (e.clientX - rect.left) / rect.width;
// 计算点击位置对应的视频时间
const duration = art.video.duration;
let clickTime = percentage * duration;
// 处理视频接近结尾的情况
if (duration - clickTime < 1) {
// 如果点击位置非常接近结尾,稍微往前移一点
clickTime = Math.min(clickTime, duration - 1.5);
}
// 记录用户点击的位置
userClickedPosition = clickTime;
// 阻止默认事件传播,避免DPlayer内部逻辑将视频跳至末尾
e.stopPropagation();
// 直接设置视频时间
art.seek(clickTime);
}
// 处理移动端触摸事件
function handleProgressBarTouch(e) {
if (!art || !art.video || !e.touches[0]) return;
const touch = e.touches[0];
const rect = e.currentTarget.getBoundingClientRect();
const percentage = (touch.clientX - rect.left) / rect.width;
const duration = art.video.duration;
let clickTime = percentage * duration;
// 处理视频接近结尾的情况
if (duration - clickTime < 1) {
clickTime = Math.min(clickTime, duration - 1.5);
}
// 记录用户点击的位置
userClickedPosition = clickTime;
e.stopPropagation();
art.seek(clickTime);
}
}
// 在播放器初始化后添加视频到历史记录
function saveToHistory() {
// 确保 currentEpisodes 非空且有当前视频URL
if (!currentEpisodes || currentEpisodes.length === 0 || !currentVideoUrl) {
return;
}
// 尝试从URL中获取参数
const urlParams = new URLSearchParams(window.location.search);
const sourceName = urlParams.get('source') || '';
const sourceCode = urlParams.get('source') || '';
const id_from_params = urlParams.get('id'); // Get video ID from player URL (passed as 'id')
// 获取当前播放进度
let currentPosition = 0;
let videoDuration = 0;
if (art && art.video) {
currentPosition = art.video.currentTime;
videoDuration = art.video.duration;
}
// Define a show identifier: Prioritize sourceName_id, fallback to first episode URL or current video URL
let show_identifier_for_video_info;
if (sourceName && id_from_params) {
show_identifier_for_video_info = `${sourceName}_${id_from_params}`;
} else {
show_identifier_for_video_info = (currentEpisodes && currentEpisodes.length > 0) ? currentEpisodes[0] : currentVideoUrl;
}
// 构建要保存的视频信息对象
const videoInfo = {
title: currentVideoTitle,
directVideoUrl: currentVideoUrl, // Current episode's direct URL
url: `player.html?url=${encodeURIComponent(currentVideoUrl)}&title=${encodeURIComponent(currentVideoTitle)}&source=${encodeURIComponent(sourceName)}&source_code=${encodeURIComponent(sourceCode)}&id=${encodeURIComponent(id_from_params || '')}&index=${currentEpisodeIndex}&position=${Math.floor(currentPosition || 0)}`,
episodeIndex: currentEpisodeIndex,
sourceName: sourceName,
vod_id: id_from_params || '', // Store the ID from params as vod_id in history item
sourceCode: sourceCode,
showIdentifier: show_identifier_for_video_info, // Identifier for the show/series
timestamp: Date.now(),
playbackPosition: currentPosition,
duration: videoDuration,
episodes: currentEpisodes && currentEpisodes.length > 0 ? [...currentEpisodes] : []
};
try {
const history = JSON.parse(localStorage.getItem('viewingHistory') || '[]');
// 检查是否已经存在相同的系列记录 (基于标题、来源和 showIdentifier)
const existingIndex = history.findIndex(item =>
item.title === videoInfo.title &&
item.sourceName === videoInfo.sourceName &&
item.showIdentifier === videoInfo.showIdentifier
);
if (existingIndex !== -1) {
// 存在则更新现有记录的当前集数、时间戳、播放进度和URL等
const existingItem = history[existingIndex];
existingItem.episodeIndex = videoInfo.episodeIndex;
existingItem.timestamp = videoInfo.timestamp;
existingItem.sourceName = videoInfo.sourceName; // Should be consistent, but update just in case
existingItem.sourceCode = videoInfo.sourceCode;
existingItem.vod_id = videoInfo.vod_id;
// Update URLs to reflect the current episode being watched
existingItem.directVideoUrl = videoInfo.directVideoUrl; // Current episode's direct URL
existingItem.url = videoInfo.url; // Player link for the current episode
// 更新播放进度信息
existingItem.playbackPosition = videoInfo.playbackPosition > 10 ? videoInfo.playbackPosition : (existingItem.playbackPosition || 0);
existingItem.duration = videoInfo.duration || existingItem.duration;
// 更新集数列表(如果新的集数列表与存储的不同,例如集数增加了)
if (videoInfo.episodes && videoInfo.episodes.length > 0) {
if (!existingItem.episodes ||
!Array.isArray(existingItem.episodes) ||
existingItem.episodes.length !== videoInfo.episodes.length ||
!videoInfo.episodes.every((ep, i) => ep === existingItem.episodes[i])) { // Basic check for content change
existingItem.episodes = [...videoInfo.episodes]; // Deep copy
}
}
// 移到最前面
const updatedItem = history.splice(existingIndex, 1)[0];
history.unshift(updatedItem);
} else {
// 添加新记录到最前面
history.unshift(videoInfo);
}
// 限制历史记录数量为50条
if (history.length > 50) history.splice(50);
localStorage.setItem('viewingHistory', JSON.stringify(history));
} catch (e) {
}
}
// 显示恢复位置提示
function showPositionRestoreHint(position) {
if (!position || position < 10) return;
// 创建提示元素
const hint = document.createElement('div');
hint.className = 'position-restore-hint';
hint.innerHTML = `
已从 ${formatTime(position)} 继续播放
`;
// 添加到播放器容器
const playerContainer = document.querySelector('.player-container'); // Ensure this selector is correct
if (playerContainer) { // Check if playerContainer exists
playerContainer.appendChild(hint);
} else {
return; // Exit if container not found
}
// 显示提示
setTimeout(() => {
hint.classList.add('show');
// 3秒后隐藏
setTimeout(() => {
hint.classList.remove('show');
setTimeout(() => hint.remove(), 300);
}, 3000);
}, 100);
}
// 格式化时间为 mm:ss 格式
function formatTime(seconds) {
if (isNaN(seconds)) return '00:00';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
// 开始定期保存播放进度
function startProgressSaveInterval() {
// 清除可能存在的旧计时器
if (progressSaveInterval) {
clearInterval(progressSaveInterval);
}
// 每30秒保存一次播放进度
progressSaveInterval = setInterval(saveCurrentProgress, 30000);
}
// 保存当前播放进度
function saveCurrentProgress() {
if (!art || !art.video) return;
const currentTime = art.video.currentTime;
const duration = art.video.duration;
if (!duration || currentTime < 1) return;
// 在localStorage中保存进度
const progressKey = `videoProgress_${getVideoId()}`;
const progressData = {
position: currentTime,
duration: duration,
timestamp: Date.now()
};
try {
localStorage.setItem(progressKey, JSON.stringify(progressData));
// --- 新增:同步更新 viewingHistory 中的进度 ---
try {
const historyRaw = localStorage.getItem('viewingHistory');
if (historyRaw) {
const history = JSON.parse(historyRaw);
// 用 title + 集数索引唯一标识
const idx = history.findIndex(item =>
item.title === currentVideoTitle &&
(item.episodeIndex === undefined || item.episodeIndex === currentEpisodeIndex)
);
if (idx !== -1) {
// 只在进度有明显变化时才更新,减少写入
if (
Math.abs((history[idx].playbackPosition || 0) - currentTime) > 2 ||
Math.abs((history[idx].duration || 0) - duration) > 2
) {
history[idx].playbackPosition = currentTime;
history[idx].duration = duration;
history[idx].timestamp = Date.now();
localStorage.setItem('viewingHistory', JSON.stringify(history));
}
}
}
} catch (e) {
}
} catch (e) {
}
}
// 设置移动端长按三倍速播放功能
function setupLongPressSpeedControl() {
if (!art || !art.video) return;
const playerElement = document.getElementById('player');
let longPressTimer = null;
let originalPlaybackRate = 1.0;
let isLongPress = false;
// 显示快速提示
function showSpeedHint(speed) {
showShortcutHint(`${speed}倍速`, 'right');
}
// 禁用右键
playerElement.oncontextmenu = () => {
// 检测是否为移动设备
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// 只在移动设备上禁用右键
if (isMobile) {
const dplayerMenu = document.querySelector(".dplayer-menu");
const dplayerMask = document.querySelector(".dplayer-mask");
if (dplayerMenu) dplayerMenu.style.display = "none";
if (dplayerMask) dplayerMask.style.display = "none";
return false;
}
return true; // 在桌面设备上允许右键菜单
};
// 触摸开始事件
playerElement.addEventListener('touchstart', function (e) {
// 检查视频是否正在播放,如果没有播放则不触发长按功能
if (art.video.paused) {
return; // 视频暂停时不触发长按功能
}
// 保存原始播放速度
originalPlaybackRate = art.video.playbackRate;
// 设置长按计时器
longPressTimer = setTimeout(() => {
// 再次检查视频是否仍在播放
if (art.video.paused) {
clearTimeout(longPressTimer);
longPressTimer = null;
return;
}
// 长按超过500ms,设置为3倍速
art.video.playbackRate = 3.0;
isLongPress = true;
showSpeedHint(3.0);
// 只在确认为长按时阻止默认行为
e.preventDefault();
}, 500);
}, { passive: false });
// 触摸结束事件
playerElement.addEventListener('touchend', function (e) {
// 清除长按计时器
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
// 如果是长按状态,恢复原始播放速度
if (isLongPress) {
art.video.playbackRate = originalPlaybackRate;
isLongPress = false;
showSpeedHint(originalPlaybackRate);
// 阻止长按后的点击事件
e.preventDefault();
}
// 如果不是长按,则允许正常的点击事件(暂停/播放)
});
// 触摸取消事件
playerElement.addEventListener('touchcancel', function () {
// 清除长按计时器
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
// 如果是长按状态,恢复原始播放速度
if (isLongPress) {
art.video.playbackRate = originalPlaybackRate;
isLongPress = false;
}
});
// 触摸移动事件 - 防止在长按时触发页面滚动
playerElement.addEventListener('touchmove', function (e) {
if (isLongPress) {
e.preventDefault();
}
}, { passive: false });
// 视频暂停时取消长按状态
art.video.addEventListener('pause', function () {
if (isLongPress) {
art.video.playbackRate = originalPlaybackRate;
isLongPress = false;
}
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
}
// 清除视频进度记录
function clearVideoProgress() {
const progressKey = `videoProgress_${getVideoId()}`;
try {
localStorage.removeItem(progressKey);
} catch (e) {
}
}
// 获取视频唯一标识
function getVideoId() {
// 使用视频标题和集数索引作为唯一标识
// If currentVideoUrl is available and more unique, prefer it. Otherwise, fallback.
if (currentVideoUrl) {
return `${encodeURIComponent(currentVideoUrl)}`;
}
return `${encodeURIComponent(currentVideoTitle)}_${currentEpisodeIndex}`;
}
let controlsLocked = false;
function toggleControlsLock() {
const container = document.getElementById('playerContainer');
controlsLocked = !controlsLocked;
container.classList.toggle('controls-locked', controlsLocked);
const icon = document.getElementById('lockIcon');
// 切换图标:锁 / 解锁
icon.innerHTML = controlsLocked
? ' '
: ' ';
}
// 支持在iframe中关闭播放器
function closeEmbeddedPlayer() {
try {
if (window.self !== window.top) {
// 如果在iframe中,尝试调用父窗口的关闭方法
if (window.parent && typeof window.parent.closeVideoPlayer === 'function') {
window.parent.closeVideoPlayer();
return true;
}
}
} catch (e) {
console.error('尝试关闭嵌入式播放器失败:', e);
}
return false;
}
function renderResourceInfoBar() {
// 获取容器元素
const container = document.getElementById('resourceInfoBarContainer');
if (!container) {
console.error('找不到资源信息卡片容器');
return;
}
// 获取当前视频 source_code
const urlParams = new URLSearchParams(window.location.search);
const currentSource = urlParams.get('source') || '';
// 显示临时加载状态
container.innerHTML = `
加载中...
-
切换资源
`;
// 查找当前源名称,从 API_SITES 和 custom_api 中查找即可
let resourceName = currentSource
if (currentSource && API_SITES[currentSource]) {
resourceName = API_SITES[currentSource].name;
}
if (resourceName === currentSource) {
const customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]');
const customIndex = parseInt(currentSource.replace('custom_', ''), 10);
if (customAPIs[customIndex]) {
resourceName = customAPIs[customIndex].name || '自定义资源';
}
}
container.innerHTML = `
${resourceName}
${currentEpisodes.length} 个视频
切换资源
`;
}
// 测试视频源速率的函数
async function testVideoSourceSpeed(sourceKey, vodId) {
try {
const startTime = performance.now();
// 构建API参数
let apiParams = '';
if (sourceKey.startsWith('custom_')) {
const customIndex = sourceKey.replace('custom_', '');
const customApi = getCustomApiInfo(customIndex);
if (!customApi) {
return { speed: -1, error: 'API配置无效' };
}
if (customApi.detail) {
apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom';
} else {
apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom';
}
} else {
apiParams = '&source=' + sourceKey;
}
// 添加时间戳防止缓存
const timestamp = new Date().getTime();
const cacheBuster = `&_t=${timestamp}`;
// 获取视频详情
const response = await fetch(`/api/detail?id=${encodeURIComponent(vodId)}${apiParams}${cacheBuster}`, {
method: 'GET',
cache: 'no-cache'
});
if (!response.ok) {
return { speed: -1, error: '获取失败' };
}
const data = await response.json();
if (!data.episodes || data.episodes.length === 0) {
return { speed: -1, error: '无播放源' };
}
// 测试第一个播放链接的响应速度
const firstEpisodeUrl = data.episodes[0];
if (!firstEpisodeUrl) {
return { speed: -1, error: '链接无效' };
}
// 测试视频链接响应时间
const videoTestStart = performance.now();
try {
const videoResponse = await fetch(firstEpisodeUrl, {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-cache',
signal: AbortSignal.timeout(5000) // 5秒超时
});
const videoTestEnd = performance.now();
const totalTime = videoTestEnd - startTime;
// 返回总响应时间(毫秒)
return {
speed: Math.round(totalTime),
episodes: data.episodes.length,
error: null
};
} catch (videoError) {
// 如果视频链接测试失败,只返回API响应时间
const apiTime = performance.now() - startTime;
return {
speed: Math.round(apiTime),
episodes: data.episodes.length,
error: null,
note: 'API响应'
};
}
} catch (error) {
return {
speed: -1,
error: error.name === 'AbortError' ? '超时' : '测试失败'
};
}
}
// 格式化速度显示
function formatSpeedDisplay(speedResult) {
if (speedResult.speed === -1) {
return `❌ ${speedResult.error} `;
}
const speed = speedResult.speed;
let className = 'speed-indicator good';
let icon = '🟢';
if (speed > 2000) {
className = 'speed-indicator poor';
icon = '🔴';
} else if (speed > 1000) {
className = 'speed-indicator medium';
icon = '🟡';
}
const note = speedResult.note ? ` (${speedResult.note})` : '';
return `${icon} ${speed}ms${note} `;
}
async function showSwitchResourceModal() {
const urlParams = new URLSearchParams(window.location.search);
const currentSourceCode = urlParams.get('source');
const currentVideoId = urlParams.get('id');
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modalTitle');
const modalContent = document.getElementById('modalContent');
modalTitle.innerHTML = `${currentVideoTitle} `;
modalContent.innerHTML = '正在加载资源列表...
';
modal.classList.remove('hidden');
// 搜索
const resourceOptions = selectedAPIs.map((curr) => {
if (API_SITES[curr]) {
return { key: curr, name: API_SITES[curr].name };
}
const customIndex = parseInt(curr.replace('custom_', ''), 10);
if (customAPIs[customIndex]) {
return { key: curr, name: customAPIs[customIndex].name || '自定义资源' };
}
return { key: curr, name: '未知资源' };
});
let allResults = {};
await Promise.all(resourceOptions.map(async (opt) => {
let queryResult = await searchByAPIAndKeyWord(opt.key, currentVideoTitle);
if (queryResult.length == 0) {
return
}
// 优先取完全同名资源,否则默认取第一个
let result = queryResult[0]
queryResult.forEach((res) => {
if (res.vod_name == currentVideoTitle) {
result = res;
}
})
allResults[opt.key] = result;
}));
// 更新状态显示:开始速率测试
modalContent.innerHTML = '正在测试各资源速率...
';
// 同时测试所有资源的速率
const speedResults = {};
await Promise.all(Object.entries(allResults).map(async ([sourceKey, result]) => {
if (result) {
speedResults[sourceKey] = await testVideoSourceSpeed(sourceKey, result.vod_id);
}
}));
// 对结果进行排序
const sortedResults = Object.entries(allResults).sort(([keyA, resultA], [keyB, resultB]) => {
// 当前播放的源放在最前面
const isCurrentA = String(keyA) === String(currentSourceCode) && String(resultA.vod_id) === String(currentVideoId);
const isCurrentB = String(keyB) === String(currentSourceCode) && String(resultB.vod_id) === String(currentVideoId);
if (isCurrentA && !isCurrentB) return -1;
if (!isCurrentA && isCurrentB) return 1;
// 其余按照速度排序,速度快的在前面(速度为-1表示失败,排到最后)
const speedA = speedResults[keyA]?.speed || 99999;
const speedB = speedResults[keyB]?.speed || 99999;
if (speedA === -1 && speedB !== -1) return 1;
if (speedA !== -1 && speedB === -1) return -1;
if (speedA === -1 && speedB === -1) return 0;
return speedA - speedB;
});
// 渲染资源列表
let html = '';
for (const [sourceKey, result] of sortedResults) {
if (!result) continue;
// 修复 isCurrentSource 判断,确保类型一致
const isCurrentSource = String(sourceKey) === String(currentSourceCode) && String(result.vod_id) === String(currentVideoId);
const sourceName = resourceOptions.find(opt => opt.key === sourceKey)?.name || '未知资源';
const speedResult = speedResults[sourceKey] || { speed: -1, error: '未测试' };
html += `
`;
}
html += '
';
modalContent.innerHTML = html;
}
// 切换资源的函数
async function switchToResource(sourceKey, vodId) {
// 关闭模态框
document.getElementById('modal').classList.add('hidden');
showLoading();
try {
// 构建API参数
let apiParams = '';
// 处理自定义API源
if (sourceKey.startsWith('custom_')) {
const customIndex = sourceKey.replace('custom_', '');
const customApi = getCustomApiInfo(customIndex);
if (!customApi) {
showToast('自定义API配置无效', 'error');
hideLoading();
return;
}
// 传递 detail 字段
if (customApi.detail) {
apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom';
} else {
apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom';
}
} else {
// 内置API
apiParams = '&source=' + sourceKey;
}
// Add a timestamp to prevent caching
const timestamp = new Date().getTime();
const cacheBuster = `&_t=${timestamp}`;
const response = await fetch(`/api/detail?id=${encodeURIComponent(vodId)}${apiParams}${cacheBuster}`);
const data = await response.json();
if (!data.episodes || data.episodes.length === 0) {
showToast('未找到播放资源', 'error');
hideLoading();
return;
}
// 获取当前播放的集数索引
const currentIndex = currentEpisodeIndex;
// 确定要播放的集数索引
let targetIndex = 0;
if (currentIndex < data.episodes.length) {
// 如果当前集数在新资源中存在,则使用相同集数
targetIndex = currentIndex;
}
// 获取目标集数的URL
const targetUrl = data.episodes[targetIndex];
// 构建播放页面URL
const watchUrl = `player.html?id=${vodId}&source=${sourceKey}&url=${encodeURIComponent(targetUrl)}&index=${targetIndex}&title=${encodeURIComponent(currentVideoTitle)}`;
// 保存当前状态到localStorage
try {
localStorage.setItem('currentVideoTitle', data.vod_name || '未知视频');
localStorage.setItem('currentEpisodes', JSON.stringify(data.episodes));
localStorage.setItem('currentEpisodeIndex', targetIndex);
localStorage.setItem('currentSourceCode', sourceKey);
localStorage.setItem('lastPlayTime', Date.now());
} catch (e) {
console.error('保存播放状态失败:', e);
}
// 跳转到播放页面
window.location.href = watchUrl;
} catch (error) {
console.error('切换资源失败:', error);
showToast('切换资源失败,请稍后重试', 'error');
} finally {
hideLoading();
}
}
================================================
FILE: js/proxy-auth.js
================================================
/**
* 代理请求鉴权模块
* 为代理请求添加基于 PASSWORD 的鉴权机制
*/
// 从全局配置获取密码哈希(如果存在)
let cachedPasswordHash = null;
/**
* 获取当前会话的密码哈希
*/
async function getPasswordHash() {
if (cachedPasswordHash) {
return cachedPasswordHash;
}
// 1. 优先从已存储的代理鉴权哈希获取
const storedHash = localStorage.getItem('proxyAuthHash');
if (storedHash) {
cachedPasswordHash = storedHash;
return storedHash;
}
// 2. 尝试从密码验证状态获取(password.js 验证后存储的哈希)
const passwordVerified = localStorage.getItem('passwordVerified');
const storedPasswordHash = localStorage.getItem('passwordHash');
if (passwordVerified === 'true' && storedPasswordHash) {
localStorage.setItem('proxyAuthHash', storedPasswordHash);
cachedPasswordHash = storedPasswordHash;
return storedPasswordHash;
}
// 3. 尝试从用户输入的密码生成哈希
const userPassword = localStorage.getItem('userPassword');
if (userPassword) {
try {
// 动态导入 sha256 函数
const { sha256 } = await import('./sha256.js');
const hash = await sha256(userPassword);
localStorage.setItem('proxyAuthHash', hash);
cachedPasswordHash = hash;
return hash;
} catch (error) {
console.error('生成密码哈希失败:', error);
}
}
// 4. 如果用户没有设置密码,尝试使用环境变量中的密码哈希
if (window.__ENV__ && window.__ENV__.PASSWORD) {
cachedPasswordHash = window.__ENV__.PASSWORD;
return window.__ENV__.PASSWORD;
}
return null;
}
/**
* 为代理请求URL添加鉴权参数
*/
async function addAuthToProxyUrl(url) {
try {
const hash = await getPasswordHash();
if (!hash) {
console.warn('无法获取密码哈希,代理请求可能失败');
return url;
}
// 添加时间戳防止重放攻击
const timestamp = Date.now();
// 检查URL是否已包含查询参数
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}auth=${encodeURIComponent(hash)}&t=${timestamp}`;
} catch (error) {
console.error('添加代理鉴权失败:', error);
return url;
}
}
/**
* 验证代理请求的鉴权
*/
function validateProxyAuth(authHash, serverPasswordHash, timestamp) {
if (!authHash || !serverPasswordHash) {
return false;
}
// 验证哈希是否匹配
if (authHash !== serverPasswordHash) {
return false;
}
// 验证时间戳(10分钟有效期)
const now = Date.now();
const maxAge = 10 * 60 * 1000; // 10分钟
if (timestamp && (now - parseInt(timestamp)) > maxAge) {
console.warn('代理请求时间戳过期');
return false;
}
return true;
}
/**
* 清除缓存的鉴权信息
*/
function clearAuthCache() {
cachedPasswordHash = null;
localStorage.removeItem('proxyAuthHash');
}
// 监听密码变化,清除缓存
window.addEventListener('storage', (e) => {
if (e.key === 'userPassword' || (window.PASSWORD_CONFIG && e.key === window.PASSWORD_CONFIG.localStorageKey)) {
clearAuthCache();
}
});
// 导出函数
window.ProxyAuth = {
addAuthToProxyUrl,
validateProxyAuth,
clearAuthCache,
getPasswordHash
};
================================================
FILE: js/pwa-register.js
================================================
// PWA 注册
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js');
});
}
================================================
FILE: js/search.js
================================================
async function searchByAPIAndKeyWord(apiId, query) {
try {
let apiUrl, apiName, apiBaseUrl;
// 处理自定义API
if (apiId.startsWith('custom_')) {
const customIndex = apiId.replace('custom_', '');
const customApi = getCustomApiInfo(customIndex);
if (!customApi) return [];
apiBaseUrl = customApi.url;
apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
apiName = customApi.name;
} else {
// 内置API
if (!API_SITES[apiId]) return [];
apiBaseUrl = API_SITES[apiId].api;
apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
apiName = API_SITES[apiId].name;
}
// 添加超时处理
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
// 添加鉴权参数到代理URL
const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ?
await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) :
PROXY_URL + encodeURIComponent(apiUrl);
const response = await fetch(proxiedUrl, {
headers: API_CONFIG.search.headers,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
return [];
}
const data = await response.json();
if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
return [];
}
// 处理第一页结果
const results = data.list.map(item => ({
...item,
source_name: apiName,
source_code: apiId,
api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined
}));
// 获取总页数
const pageCount = data.pagecount || 1;
// 确定需要获取的额外页数 (最多获取maxPages页)
const pagesToFetch = Math.min(pageCount - 1, API_CONFIG.search.maxPages - 1);
// 如果有额外页数,获取更多页的结果
if (pagesToFetch > 0) {
const additionalPagePromises = [];
for (let page = 2; page <= pagesToFetch + 1; page++) {
// 构建分页URL
const pageUrl = apiBaseUrl + API_CONFIG.search.pagePath
.replace('{query}', encodeURIComponent(query))
.replace('{page}', page);
// 创建获取额外页的Promise
const pagePromise = (async () => {
try {
const pageController = new AbortController();
const pageTimeoutId = setTimeout(() => pageController.abort(), 15000);
// 添加鉴权参数到代理URL
const proxiedPageUrl = await window.ProxyAuth?.addAuthToProxyUrl ?
await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(pageUrl)) :
PROXY_URL + encodeURIComponent(pageUrl);
const pageResponse = await fetch(proxiedPageUrl, {
headers: API_CONFIG.search.headers,
signal: pageController.signal
});
clearTimeout(pageTimeoutId);
if (!pageResponse.ok) return [];
const pageData = await pageResponse.json();
if (!pageData || !pageData.list || !Array.isArray(pageData.list)) return [];
// 处理当前页结果
return pageData.list.map(item => ({
...item,
source_name: apiName,
source_code: apiId,
api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined
}));
} catch (error) {
console.warn(`API ${apiId} 第${page}页搜索失败:`, error);
return [];
}
})();
additionalPagePromises.push(pagePromise);
}
// 等待所有额外页的结果
const additionalResults = await Promise.all(additionalPagePromises);
// 合并所有页的结果
additionalResults.forEach(pageResults => {
if (pageResults.length > 0) {
results.push(...pageResults);
}
});
}
return results;
} catch (error) {
console.warn(`API ${apiId} 搜索失败:`, error);
return [];
}
}
================================================
FILE: js/sha256.js
================================================
export async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
================================================
FILE: js/ui.js
================================================
// UI相关函数
function toggleSettings(e) {
// 强化的密码保护校验 - 防止绕过
try {
if (window.ensurePasswordProtection) {
window.ensurePasswordProtection();
} else {
// 兼容性检查
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
showPasswordModal && showPasswordModal();
return;
}
}
}
} catch (error) {
console.warn('Password protection check failed:', error.message);
return;
}
// 阻止事件冒泡,防止触发document的点击事件
e && e.stopPropagation();
const panel = document.getElementById('settingsPanel');
panel.classList.toggle('show');
}
// 改进的Toast显示函数 - 支持队列显示多个Toast
const toastQueue = [];
let isShowingToast = false;
function showToast(message, type = 'error') {
// 首先确保toast元素存在
let toast = document.getElementById('toast');
let toastMessage = document.getElementById('toastMessage');
// 如果toast元素不存在,创建它
if (!toast) {
toast = document.createElement('div');
toast.id = 'toast';
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50 opacity-0';
toast.style = 'z-index: 2147483647'
toastMessage = document.createElement('p');
toastMessage.id = 'toastMessage';
toast.appendChild(toastMessage);
document.body.appendChild(toast);
}
// 将新的toast添加到队列
toastQueue.push({ message, type });
// 如果当前没有显示中的toast,则开始显示
if (!isShowingToast) {
showNextToast();
}
}
function showNextToast() {
if (toastQueue.length === 0) {
isShowingToast = false;
return;
}
isShowingToast = true;
const { message, type } = toastQueue.shift();
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toastMessage');
const bgColors = {
'error': 'bg-red-500',
'success': 'bg-green-500',
'info': 'bg-blue-500',
'warning': 'bg-yellow-500'
};
const bgColor = bgColors[type] || bgColors.error;
toast.className = `fixed top-4 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 ${bgColor} text-white z-50`;
toastMessage.textContent = message;
// 显示提示
toast.style.opacity = '1';
toast.style.transform = 'translateX(-50%) translateY(0)';
// 3秒后自动隐藏
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(-50%) translateY(-100%)';
// 等待动画完成后显示下一个toast
setTimeout(() => {
showNextToast();
}, 300);
}, 3000);
}
// 添加显示/隐藏 loading 的函数
let loadingTimeoutId = null;
function showLoading(message = '加载中...') {
// 清除任何现有的超时
if (loadingTimeoutId) {
clearTimeout(loadingTimeoutId);
}
const loading = document.getElementById('loading');
const messageEl = loading.querySelector('p');
messageEl.textContent = message;
loading.style.display = 'flex';
// 设置30秒后自动关闭loading,防止无限loading
loadingTimeoutId = setTimeout(() => {
hideLoading();
showToast('操作超时,请稍后重试', 'warning');
}, 30000);
}
function hideLoading() {
// 清除超时
if (loadingTimeoutId) {
clearTimeout(loadingTimeoutId);
loadingTimeoutId = null;
}
const loading = document.getElementById('loading');
loading.style.display = 'none';
}
function updateSiteStatus(isAvailable) {
const statusEl = document.getElementById('siteStatus');
if (isAvailable) {
statusEl.innerHTML = '● 可用';
} else {
statusEl.innerHTML = '● 不可用';
}
}
function closeModal() {
document.getElementById('modal').classList.add('hidden');
// 清除 iframe 内容
document.getElementById('modalContent').innerHTML = '';
}
// 获取搜索历史的增强版本 - 支持新旧格式
function getSearchHistory() {
try {
const data = localStorage.getItem(SEARCH_HISTORY_KEY);
if (!data) return [];
const parsed = JSON.parse(data);
// 检查是否是数组
if (!Array.isArray(parsed)) return [];
// 支持旧格式(字符串数组)和新格式(对象数组)
return parsed.map(item => {
if (typeof item === 'string') {
return { text: item, timestamp: 0 };
}
return item;
}).filter(item => item && item.text);
} catch (e) {
console.error('获取搜索历史出错:', e);
return [];
}
}
// 保存搜索历史的增强版本 - 添加时间戳和最大数量限制,现在缓存2个月
function saveSearchHistory(query) {
if (!query || !query.trim()) return;
// 清理输入,防止XSS
query = query.trim().substring(0, 50).replace(//g, '>');
let history = getSearchHistory();
// 获取当前时间
const now = Date.now();
// 过滤掉超过2个月的记录(约60天,60*24*60*60*1000 = 5184000000毫秒)
history = history.filter(item =>
typeof item === 'object' && item.timestamp && (now - item.timestamp < 5184000000)
);
// 删除已存在的相同项
history = history.filter(item =>
typeof item === 'object' ? item.text !== query : item !== query
);
// 新项添加到开头,包含时间戳
history.unshift({
text: query,
timestamp: now
});
// 限制历史记录数量
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
try {
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
} catch (e) {
console.error('保存搜索历史失败:', e);
// 如果存储失败(可能是localStorage已满),尝试清理旧数据
try {
localStorage.removeItem(SEARCH_HISTORY_KEY);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, 3)));
} catch (e2) {
console.error('再次保存搜索历史失败:', e2);
}
}
renderSearchHistory();
}
// 渲染最近搜索历史的增强版本
function renderSearchHistory() {
const historyContainer = document.getElementById('recentSearches');
if (!historyContainer) return;
const history = getSearchHistory();
if (history.length === 0) {
historyContainer.innerHTML = '';
return;
}
// 创建一个包含标题和清除按钮的行
historyContainer.innerHTML = `
`;
history.forEach(item => {
const tag = document.createElement('button');
tag.className = 'search-tag flex items-center gap-1';
const textSpan = document.createElement('span');
textSpan.textContent = item.text;
tag.appendChild(textSpan);
// 添加删除按钮
const deleteButton = document.createElement('span');
deleteButton.className = 'pl-1 text-gray-500 hover:text-red-500 transition-colors';
deleteButton.innerHTML = ' ';
deleteButton.onclick = function(e) {
// 阻止事件冒泡,避免触发搜索
e.stopPropagation();
// 删除对应历史记录
deleteSingleSearchHistory(item.text);
// 重新渲染搜索历史
renderSearchHistory();
};
tag.appendChild(deleteButton);
// 添加时间提示(如果有时间戳)
if (item.timestamp) {
const date = new Date(item.timestamp);
tag.title = `搜索于: ${date.toLocaleString()}`;
}
tag.onclick = function() {
document.getElementById('searchInput').value = item.text;
search();
};
historyContainer.appendChild(tag);
});
}
// 删除单条搜索历史记录
function deleteSingleSearchHistory(query) {
// 当url中包含删除的关键词时,页面刷新后会自动加入历史记录,导致误认为删除功能有bug。此问题无需修复,功能无实际影响。
try {
let history = getSearchHistory();
// 过滤掉要删除的记录
history = history.filter(item => item.text !== query);
console.log('更新后的搜索历史:', history);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
} catch (e) {
console.error('删除单条搜索历史失败:', e);
showToast('删除单条搜索历史失败', 'error');
}
}
// 增加清除搜索历史功能
function clearSearchHistory() {
// 密码保护校验
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
showPasswordModal && showPasswordModal();
return;
}
}
try {
localStorage.removeItem(SEARCH_HISTORY_KEY);
renderSearchHistory();
showToast('搜索历史已清除', 'success');
} catch (e) {
console.error('清除搜索历史失败:', e);
showToast('清除搜索历史失败:', 'error');
}
}
// 历史面板相关函数
function toggleHistory(e) {
// 密码保护校验
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
showPasswordModal && showPasswordModal();
return;
}
}
if (e) e.stopPropagation();
const panel = document.getElementById('historyPanel');
if (panel) {
panel.classList.toggle('show');
// 如果打开了历史记录面板,则加载历史数据
if (panel.classList.contains('show')) {
loadViewingHistory();
}
// 如果设置面板是打开的,则关闭它
const settingsPanel = document.getElementById('settingsPanel');
if (settingsPanel && settingsPanel.classList.contains('show')) {
settingsPanel.classList.remove('show');
}
}
}
// 格式化时间戳为友好的日期时间格式
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
// 小于1小时,显示"X分钟前"
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000);
return minutes <= 0 ? '刚刚' : `${minutes}分钟前`;
}
// 小于24小时,显示"X小时前"
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}小时前`;
}
// 小于7天,显示"X天前"
if (diff < 604800000) {
const days = Math.floor(diff / 86400000);
return `${days}天前`;
}
// 其他情况,显示完整日期
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hour = date.getHours().toString().padStart(2, '0');
const minute = date.getMinutes().toString().padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
// 获取观看历史记录
function getViewingHistory() {
try {
const data = localStorage.getItem('viewingHistory');
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('获取观看历史失败:', e);
return [];
}
}
// 加载观看历史并渲染
function loadViewingHistory() {
const historyList = document.getElementById('historyList');
if (!historyList) return;
const history = getViewingHistory();
if (history.length === 0) {
historyList.innerHTML = `暂无观看记录
`;
return;
}
// 渲染历史记录
historyList.innerHTML = history.map(item => {
// 防止XSS
const safeTitle = item.title
.replace(//g, '>')
.replace(/"/g, '"');
const safeSource = item.sourceName ?
item.sourceName.replace(//g, '>').replace(/"/g, '"') :
'未知来源';
const episodeText = item.episodeIndex !== undefined ?
`第${item.episodeIndex + 1}集` : '';
// 格式化剧集信息
let episodeInfoHtml = '';
if (item.episodes && Array.isArray(item.episodes) && item.episodes.length > 0) {
const totalEpisodes = item.episodes.length;
const syncStatus = item.lastSyncTime ?
`✓ ` :
`⚠ `;
episodeInfoHtml = `共${totalEpisodes}集 ${syncStatus} `;
}
// 格式化进度信息
let progressHtml = '';
if (item.playbackPosition && item.duration && item.playbackPosition > 10 && item.playbackPosition < item.duration * 0.95) {
const percent = Math.round((item.playbackPosition / item.duration) * 100);
const formattedTime = formatPlaybackTime(item.playbackPosition);
const formattedDuration = formatPlaybackTime(item.duration);
progressHtml = `
${formattedTime} / ${formattedDuration}
`;
}
// 为防止XSS,使用encodeURIComponent编码URL
const safeURL = encodeURIComponent(item.url);
// 构建历史记录项HTML,添加删除按钮,需要放在position:relative的容器中
return `
${safeTitle}
${episodeText}
${episodeText ? '· ' : ''}
${safeSource}
${episodeInfoHtml ? '· ' : ''}
${episodeInfoHtml}
${progressHtml}
${formatTimestamp(item.timestamp)}
`;
}).join('');
// 检查是否存在较多历史记录,添加底部边距确保底部按钮不会挡住内容
if (history.length > 5) {
historyList.classList.add('pb-4');
}
}
// 格式化播放时间为 mm:ss 格式
function formatPlaybackTime(seconds) {
if (!seconds || isNaN(seconds)) return '00:00';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
// 删除单个历史记录项
function deleteHistoryItem(encodedUrl) {
try {
// 解码URL
const url = decodeURIComponent(encodedUrl);
// 获取当前历史记录
const history = getViewingHistory();
// 过滤掉要删除的项
const newHistory = history.filter(item => item.url !== url);
// 保存回localStorage
localStorage.setItem('viewingHistory', JSON.stringify(newHistory));
// 重新加载历史记录显示
loadViewingHistory();
// 显示成功提示
showToast('已删除该记录', 'success');
} catch (e) {
console.error('删除历史记录项失败:', e);
showToast('删除记录失败', 'error');
}
}
// 从历史记录播放
async function playFromHistory(url, title, episodeIndex, playbackPosition = 0) {
// console.log('[playFromHistory in ui.js] Called with:', { url, title, episodeIndex, playbackPosition }); // Log 1
try {
let episodesList = [];
let historyItem = null; // To store the full history item
let syncSuccessful = false;
// 检查viewingHistory,查找匹配的项
const historyRaw = localStorage.getItem('viewingHistory');
if (historyRaw) {
const history = JSON.parse(historyRaw);
historyItem = history.find(item => item.url === url);
// console.log('[playFromHistory in ui.js] Found historyItem:', historyItem ? JSON.parse(JSON.stringify(historyItem)) : null); // Log 2 (stringify/parse for deep copy)
if (historyItem) {
// console.log('[playFromHistory in ui.js] historyItem.vod_id:', historyItem.vod_id, 'historyItem.sourceName:', historyItem.sourceName); // Log 3
}
if (historyItem && historyItem.episodes && Array.isArray(historyItem.episodes)) {
episodesList = historyItem.episodes; // Default to stored episodes
// console.log(`从历史记录找到视频 "${title}" 的集数数据 (默认):`, episodesList.length);
}
}
// Always attempt to fetch fresh episode list if we have the necessary info
if (historyItem && historyItem.vod_id && historyItem.sourceName) {
// Show loading toast to indicate syncing
showToast('正在同步最新剧集列表...', 'info');
// console.log(`[playFromHistory in ui.js] Attempting to fetch details for vod_id: ${historyItem.vod_id}, sourceName: ${historyItem.sourceName}`); // Log 4
try {
// Construct the API URL for detail fetching
// historyItem.sourceName is used as the sourceCode here
// Add a cache buster timestamp
const timestamp = new Date().getTime();
const apiUrl = `/api/detail?id=${encodeURIComponent(historyItem.vod_id)}&source=${encodeURIComponent(historyItem.sourceName)}&_t=${timestamp}`;
// Add timeout to the fetch request
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch(apiUrl, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const videoDetails = await response.json();
if (videoDetails && videoDetails.episodes && videoDetails.episodes.length > 0) {
const oldEpisodeCount = episodesList.length;
episodesList = videoDetails.episodes;
syncSuccessful = true;
// Show success message with episode count info
const newEpisodeCount = episodesList.length;
if (newEpisodeCount > oldEpisodeCount) {
showToast(`已同步最新剧集列表 (${newEpisodeCount}集,新增${newEpisodeCount - oldEpisodeCount}集)`, 'success');
} else if (newEpisodeCount === oldEpisodeCount) {
showToast(`剧集列表已是最新 (${newEpisodeCount}集)`, 'success');
} else {
showToast(`已同步最新剧集列表 (${newEpisodeCount}集)`, 'success');
}
// console.log(`成功获取 "${title}" 最新剧集列表:`, episodesList.length, "集");
// Update the history item in localStorage with the fresh episodes
if (historyItem) {
historyItem.episodes = [...episodesList]; // Deep copy
historyItem.lastSyncTime = Date.now(); // Add sync timestamp
const history = JSON.parse(historyRaw); // Re-parse to ensure we have the latest version
const idx = history.findIndex(item => item.url === url);
if (idx !== -1) {
history[idx] = { ...history[idx], ...historyItem }; // Merge, ensuring other properties are kept
localStorage.setItem('viewingHistory', JSON.stringify(history));
// console.log("观看历史中的剧集列表已更新。");
}
}
} else {
// console.log(`未能获取 "${title}" 的最新剧集列表,或列表为空。将使用已存储的剧集。`);
showToast('未获取到最新剧集信息,使用缓存数据', 'warning');
}
} catch (fetchError) {
// console.error(`获取 "${title}" 最新剧集列表失败:`, fetchError, "将使用已存储的剧集。");
if (fetchError.name === 'AbortError') {
showToast('同步剧集列表超时,使用缓存数据', 'warning');
} else {
showToast('同步剧集列表失败,使用缓存数据', 'warning');
}
}
} else if (historyItem) {
// console.log(`历史记录项 "${title}" 缺少 vod_id 或 sourceName,无法刷新剧集列表。将使用已存储的剧集。`);
showToast('无法同步剧集列表,使用缓存数据', 'info');
}
// 如果在历史记录中没找到,尝试使用上一个会话的集数数据
if (episodesList.length === 0) {
try {
const storedEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');
if (storedEpisodes.length > 0) {
episodesList = storedEpisodes;
// console.log(`使用localStorage中的集数数据:`, episodesList.length);
}
} catch (e) {
// console.error('解析currentEpisodes失败:', e);
}
}
// 将剧集列表保存到localStorage,播放器页面会读取它
if (episodesList.length > 0) {
localStorage.setItem('currentEpisodes', JSON.stringify(episodesList));
// console.log(`已将剧集列表保存到localStorage,共 ${episodesList.length} 集`);
}
// 保存当前页面URL作为返回地址
let currentPath;
if (window.location.pathname.startsWith('/player.html') || window.location.pathname.startsWith('/watch.html')) {
currentPath = localStorage.getItem('lastPageUrl') || '/';
} else {
currentPath = window.location.origin + window.location.pathname + window.location.search;
}
localStorage.setItem('lastPageUrl', currentPath);
// 构造播放器URL
let playerUrl;
const sourceNameForUrl = historyItem ? historyItem.sourceName : (new URLSearchParams(new URL(url, window.location.origin).search)).get('source');
const sourceCodeForUrl = historyItem ? historyItem.sourceCode || historyItem.sourceName : (new URLSearchParams(new URL(url, window.location.origin).search)).get('source_code');
const idForUrl = historyItem ? historyItem.vod_id : '';
if (url.includes('player.html') || url.includes('watch.html')) {
// console.log('检测到嵌套播放链接,解析真实URL');
try {
const nestedUrl = new URL(url, window.location.origin);
const nestedParams = nestedUrl.searchParams;
const realVideoUrl = nestedParams.get('url') || url;
playerUrl = `player.html?url=${encodeURIComponent(realVideoUrl)}&title=${encodeURIComponent(title)}&index=${episodeIndex}&position=${Math.floor(playbackPosition || 0)}&returnUrl=${encodeURIComponent(currentPath)}`;
if (sourceNameForUrl) playerUrl += `&source=${encodeURIComponent(sourceNameForUrl)}`;
if (sourceCodeForUrl) playerUrl += `&source_code=${encodeURIComponent(sourceCodeForUrl)}`;
if (idForUrl) playerUrl += `&id=${encodeURIComponent(idForUrl)}`;
} catch (e) {
// console.error('解析嵌套URL出错:', e);
playerUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}&position=${Math.floor(playbackPosition || 0)}&returnUrl=${encodeURIComponent(currentPath)}`;
if (sourceNameForUrl) playerUrl += `&source=${encodeURIComponent(sourceNameForUrl)}`;
if (sourceCodeForUrl) playerUrl += `&source_code=${encodeURIComponent(sourceCodeForUrl)}`;
if (idForUrl) playerUrl += `&id=${encodeURIComponent(idForUrl)}`;
}
} else {
// This case should ideally not happen if 'url' is always a player.html link from history
// console.warn("Playing from history with a non-player.html URL structure. This might be an issue.");
const playUrl = new URL(url, window.location.origin);
if (!playUrl.searchParams.has('index') && episodeIndex > 0) {
playUrl.searchParams.set('index', episodeIndex);
}
playUrl.searchParams.set('position', Math.floor(playbackPosition || 0).toString());
playUrl.searchParams.set('returnUrl', encodeURIComponent(currentPath));
if (sourceNameForUrl) playUrl.searchParams.set('source', sourceNameForUrl);
if (sourceCodeForUrl) playUrl.searchParams.set('source_code', sourceCodeForUrl);
if (idForUrl) playUrl.searchParams.set('id', idForUrl);
playerUrl = playUrl.toString();
}
showVideoPlayer(playerUrl);
} catch (e) {
// console.error('从历史记录播放失败:', e);
const simpleUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}`;
showVideoPlayer(simpleUrl);
}
}
// 添加观看历史 - 确保每个视频标题只有一条记录
// IMPORTANT: videoInfo passed to this function should include a 'showIdentifier' property
// (ideally `${sourceName}_${vod_id}`), 'sourceName', and 'vod_id'.
function addToViewingHistory(videoInfo) {
// 密码保护校验
if (window.isPasswordProtected && window.isPasswordVerified) {
if (window.isPasswordProtected() && !window.isPasswordVerified()) {
showPasswordModal && showPasswordModal();
return;
}
}
try {
const history = getViewingHistory();
// Ensure videoInfo has a showIdentifier
if (!videoInfo.showIdentifier) {
if (videoInfo.sourceName && videoInfo.vod_id) {
videoInfo.showIdentifier = `${videoInfo.sourceName}_${videoInfo.vod_id}`;
} else {
// Fallback if critical IDs are missing for the preferred identifier
videoInfo.showIdentifier = (videoInfo.episodes && videoInfo.episodes.length > 0) ? videoInfo.episodes[0] : videoInfo.directVideoUrl;
// console.warn(`addToViewingHistory: videoInfo for "${videoInfo.title}" was missing sourceName or vod_id for preferred showIdentifier. Generated fallback: ${videoInfo.showIdentifier}`);
}
}
const existingIndex = history.findIndex(item =>
item.title === videoInfo.title &&
item.sourceName === videoInfo.sourceName &&
item.showIdentifier === videoInfo.showIdentifier // Strict check using the determined showIdentifier
);
if (existingIndex !== -1) {
// Exact match with showIdentifier: Update existing series entry
const existingItem = history[existingIndex];
existingItem.episodeIndex = videoInfo.episodeIndex;
existingItem.timestamp = Date.now();
existingItem.sourceName = videoInfo.sourceName || existingItem.sourceName;
existingItem.sourceCode = videoInfo.sourceCode || existingItem.sourceCode;
existingItem.vod_id = videoInfo.vod_id || existingItem.vod_id;
existingItem.directVideoUrl = videoInfo.directVideoUrl || existingItem.directVideoUrl;
existingItem.url = videoInfo.url || existingItem.url;
existingItem.playbackPosition = videoInfo.playbackPosition > 10 ? videoInfo.playbackPosition : (existingItem.playbackPosition || 0);
existingItem.duration = videoInfo.duration || existingItem.duration;
if (videoInfo.episodes && Array.isArray(videoInfo.episodes) && videoInfo.episodes.length > 0) {
if (!existingItem.episodes ||
!Array.isArray(existingItem.episodes) ||
existingItem.episodes.length !== videoInfo.episodes.length ||
!videoInfo.episodes.every((ep, i) => ep === existingItem.episodes[i])) {
existingItem.episodes = [...videoInfo.episodes];
// console.log(`更新 (addToViewingHistory) "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`);
}
}
history.splice(existingIndex, 1);
history.unshift(existingItem);
// console.log(`更新历史记录 (addToViewingHistory): "${videoInfo.title}", 第 ${videoInfo.episodeIndex !== undefined ? videoInfo.episodeIndex + 1 : 'N/A'} 集`);
} else {
// No exact match: Add as a new entry
const newItem = {
...videoInfo, // Includes the showIdentifier we ensured is present
timestamp: Date.now()
};
if (videoInfo.episodes && Array.isArray(videoInfo.episodes)) {
newItem.episodes = [...videoInfo.episodes];
} else {
newItem.episodes = [];
}
history.unshift(newItem);
// console.log(`创建新的历史记录 (addToViewingHistory): "${videoInfo.title}", Episode: ${videoInfo.episodeIndex !== undefined ? videoInfo.episodeIndex + 1 : 'N/A'}`);
}
// 限制历史记录数量为50条
const maxHistoryItems = 50;
if (history.length > maxHistoryItems) {
history.splice(maxHistoryItems);
}
// 保存到本地存储
localStorage.setItem('viewingHistory', JSON.stringify(history));
} catch (e) {
// console.error('保存观看历史失败:', e);
}
}
// 清空观看历史
function clearViewingHistory() {
try {
localStorage.removeItem('viewingHistory');
loadViewingHistory(); // 重新加载空的历史记录
showToast('观看历史已清空', 'success');
} catch (e) {
// console.error('清除观看历史失败:', e);
showToast('清除观看历史失败', 'error');
}
}
// 更新toggleSettings函数以处理历史面板互动
const originalToggleSettings = toggleSettings;
toggleSettings = function(e) {
if (e) e.stopPropagation();
// 原始设置面板切换逻辑
originalToggleSettings(e);
// 如果历史记录面板是打开的,则关闭它
const historyPanel = document.getElementById('historyPanel');
if (historyPanel && historyPanel.classList.contains('show')) {
historyPanel.classList.remove('show');
}
};
// 点击外部关闭历史面板
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('click', function(e) {
const historyPanel = document.getElementById('historyPanel');
const historyButton = document.querySelector('button[onclick="toggleHistory(event)"]');
if (historyPanel && historyButton &&
!historyPanel.contains(e.target) &&
!historyButton.contains(e.target) &&
historyPanel.classList.contains('show')) {
historyPanel.classList.remove('show');
}
});
});
// 清除本地存储缓存并刷新页面
function clearLocalStorage() {
// 确保模态框在页面上只有一个实例
let modal = document.getElementById('messageBoxModal');
if (modal) {
document.body.removeChild(modal);
}
// 创建模态框元素
modal = document.createElement('div');
modal.id = 'messageBoxModal';
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';
modal.innerHTML = `
×
警告
确定要清除页面缓存吗?
此功能会删除你的观看记录、自定义 API 接口和 Cookie,此操作不可恢复!
确定
取消
`;
// 添加模态框到页面
document.body.appendChild(modal);
// 添加事件监听器 - 关闭按钮
document.getElementById('closeBoxModal').addEventListener('click', function () {
document.body.removeChild(modal);
});
// 添加事件监听器 - 确定按钮
document.getElementById('confirmBoxModal').addEventListener('click', function () {
// 清除所有localStorage数据
localStorage.clear();
// 清除所有cookie
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
}
modal.innerHTML = `
×
提示
页面缓存和Cookie已清除,3 秒后自动刷新本页面。
`;
let countdown = 3;
const countdownElement = document.getElementById('countdown');
const countdownInterval = setInterval(() => {
countdown--;
if (countdown >= 0) {
countdownElement.textContent = countdown;
} else {
clearInterval(countdownInterval);
window.location.reload();
}
}, 1000);
});
// 添加事件监听器 - 取消按钮
document.getElementById('cancelBoxModal').addEventListener('click', function () {
document.body.removeChild(modal);
});
// 添加事件监听器 - 点击模态框外部关闭
modal.addEventListener('click', function (e) {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
// 显示配置文件导入页面
function showImportBox(fun) {
// 确保模态框在页面上只有一个实例
let modal = document.getElementById('showImportBoxModal');
if (modal) {
document.body.removeChild(modal);
}
// 创建模态框元素
modal = document.createElement('div');
modal.id = 'showImportBoxModal';
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';
modal.innerHTML = `
`;
// 添加模态框到页面
document.body.appendChild(modal);
// 添加事件监听器 - 关闭按钮
document.getElementById('closeBoxModal').addEventListener('click', function () {
document.body.removeChild(modal);
});
// 添加事件监听器 - 点击模态框外部关闭
modal.addEventListener('click', function (e) {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
// 添加事件监听器 - 拖拽文件
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('ChooseFile');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-blue-500');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-blue-500');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
fun(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => {
fun(fileInput.files[0]);
});
}
================================================
FILE: js/version-check.js
================================================
// 添加动画样式
(function() {
const style = document.createElement('style');
style.textContent = `
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
`;
document.head.appendChild(style);
})();
// 获取版本信息
async function fetchVersion(url, errorMessage, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(errorMessage);
}
return await response.text();
}
// 版本检查函数
async function checkForUpdates() {
try {
// 获取当前版本
const currentVersion = await fetchVersion('/VERSION.txt', '获取当前版本失败', {
cache: 'no-store'
});
// 获取最新版本
let latestVersion;
const VERSION_URL = {
PROXY: 'https://ghfast.top/raw.githubusercontent.com/LibreSpark/LibreTV/main/VERSION.txt',
DIRECT: 'https://raw.githubusercontent.com/LibreSpark/LibreTV/main/VERSION.txt'
};
const FETCH_TIMEOUT = 1500;
try {
// 尝试使用代理URL获取最新版本
const proxyPromise = fetchVersion(VERSION_URL.PROXY, '代理请求失败');
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('代理请求超时')), FETCH_TIMEOUT)
);
latestVersion = await Promise.race([proxyPromise, timeoutPromise]);
console.log('通过代理服务器获取版本成功');
} catch (error) {
console.log('代理请求失败,尝试直接请求:', error.message);
try {
// 代理失败后尝试直接获取
latestVersion = await fetchVersion(VERSION_URL.DIRECT, '获取最新版本失败');
console.log('直接请求获取版本成功');
} catch (directError) {
console.error('所有版本检查请求均失败:', directError);
throw new Error('无法获取最新版本信息');
}
}
console.log('当前版本:', currentVersion);
console.log('最新版本:', latestVersion);
// 清理版本字符串(移除可能的空格或换行符)
const cleanCurrentVersion = currentVersion.trim();
const cleanLatestVersion = latestVersion.trim();
// 返回版本信息
return {
current: cleanCurrentVersion,
latest: cleanLatestVersion,
hasUpdate: parseInt(cleanLatestVersion) > parseInt(cleanCurrentVersion),
currentFormatted: formatVersion(cleanCurrentVersion),
latestFormatted: formatVersion(cleanLatestVersion)
};
} catch (error) {
console.error('版本检测出错:', error);
throw error;
}
}
// 格式化版本号为可读形式 (yyyyMMddhhmm -> yyyy-MM-dd hh:mm)
function formatVersion(versionString) {
// 检测版本字符串是否有效
if (!versionString) {
return '未知版本';
}
// 清理版本字符串(移除可能的空格或换行符)
const cleanedString = versionString.trim();
// 格式化标准12位版本号
if (cleanedString.length === 12) {
const year = cleanedString.substring(0, 4);
const month = cleanedString.substring(4, 6);
const day = cleanedString.substring(6, 8);
const hour = cleanedString.substring(8, 10);
const minute = cleanedString.substring(10, 12);
return `${year}-${month}-${day} ${hour}:${minute}`;
}
return cleanedString;
}
// 创建错误版本信息元素
function createErrorVersionElement(errorMessage) {
const errorElement = document.createElement('p');
errorElement.className = 'text-gray-500 text-sm mt-1 text-center md:text-left';
errorElement.innerHTML = `版本: 检测失败 `;
errorElement.title = errorMessage;
return errorElement;
}
// 添加版本信息到页脚
function addVersionInfoToFooter() {
checkForUpdates().then(result => {
if (!result) {
// 如果版本检测失败,显示错误信息
const versionElement = createErrorVersionElement();
// 在页脚显示错误元素
displayVersionElement(versionElement);
return;
}
// 创建版本信息元素
const versionElement = document.createElement('p');
versionElement.className = 'text-gray-500 text-sm mt-1 text-center md:text-left';
// 添加当前版本信息
versionElement.innerHTML = `版本: ${result.currentFormatted}`;
// 如果有更新,添加更新提示
if (result.hasUpdate) {
versionElement.innerHTML += `
发现新版
`;
setTimeout(() => {
const updateBtn = versionElement.querySelector('span');
if (updateBtn) {
updateBtn.addEventListener('click', () => {
window.open('https://github.com/LibreSpark/LibreTV', '_blank');
});
}
}, 100);
} else {
// 如果没有更新,显示当前版本为最新版本
versionElement.innerHTML = `版本: ${result.currentFormatted} (最新版本) `;
}
// 显示版本元素
displayVersionElement(versionElement);
}).catch(error => {
console.error('版本检测出错:', error);
// 创建错误版本信息元素并显示
const errorElement = createErrorVersionElement(`错误信息: ${error.message}`);
displayVersionElement(errorElement);
});
}
// 在页脚显示版本元素的辅助函数
function displayVersionElement(element) {
// 获取页脚元素
const footerElement = document.querySelector('.footer p.text-gray-500.text-sm');
if (footerElement) {
// 在原版权信息后插入版本信息
footerElement.insertAdjacentElement('afterend', element);
} else {
// 如果找不到页脚元素,尝试在页脚区域最后添加
const footer = document.querySelector('.footer .container');
if (footer) {
footer.querySelector('div').appendChild(element);
}
}
}
// 页面加载完成后添加版本信息
document.addEventListener('DOMContentLoaded', addVersionInfoToFooter);
================================================
FILE: js/watch.js
================================================
// 获取当前URL的参数,并将它们传递给player.html
window.onload = function() {
// 获取当前URL的查询参数
const currentParams = new URLSearchParams(window.location.search);
// 创建player.html的URL对象
const playerUrlObj = new URL("player.html", window.location.origin);
// 更新状态文本
const statusElement = document.getElementById('redirect-status');
const manualRedirect = document.getElementById('manual-redirect');
let statusMessages = [
"准备视频数据中...",
"正在加载视频信息...",
"即将开始播放...",
];
let currentStatus = 0;
// 状态文本动画
let statusInterval = setInterval(() => {
if (currentStatus >= statusMessages.length) {
currentStatus = 0;
}
if (statusElement) {
statusElement.textContent = statusMessages[currentStatus];
statusElement.style.opacity = 0.7;
setTimeout(() => {
if (statusElement) statusElement.style.opacity = 1;
}, 300);
}
currentStatus++;
}, 1000);
// 确保保留所有原始参数
currentParams.forEach((value, key) => {
playerUrlObj.searchParams.set(key, value);
});
// 获取来源URL (如果存在)
const referrer = document.referrer;
// 获取当前URL中的返回URL参数(如果有)
const backUrl = currentParams.get('back');
// 确定返回URL的优先级:1. 指定的back参数 2. referrer 3. 搜索页面
let returnUrl = '';
if (backUrl) {
// 有显式指定的返回URL
returnUrl = decodeURIComponent(backUrl);
} else if (referrer && (referrer.includes('/s=') || referrer.includes('?s='))) {
// 来源是搜索页面
returnUrl = referrer;
} else if (referrer && referrer.trim() !== '') {
// 如果有referrer但不是搜索页,也使用它
returnUrl = referrer;
} else {
// 默认回到首页
returnUrl = '/';
}
// 将返回URL添加到player.html的参数中
if (!playerUrlObj.searchParams.has('returnUrl')) {
playerUrlObj.searchParams.set('returnUrl', encodeURIComponent(returnUrl));
}
// 同时保存在localStorage中,作为备用
localStorage.setItem('lastPageUrl', returnUrl);
// 标记来自搜索页面
if (returnUrl.includes('/s=') || returnUrl.includes('?s=')) {
localStorage.setItem('cameFromSearch', 'true');
localStorage.setItem('searchPageUrl', returnUrl);
}
// 获取最终的URL字符串
const finalPlayerUrl = playerUrlObj.toString();
// 更新手动重定向链接
if (manualRedirect) {
manualRedirect.href = finalPlayerUrl;
}
// 更新meta refresh标签
const metaRefresh = document.querySelector('meta[http-equiv="refresh"]');
if (metaRefresh) {
metaRefresh.content = `3; url=${finalPlayerUrl}`;
}
// 重定向到播放器页面
setTimeout(() => {
clearInterval(statusInterval);
window.location.href = finalPlayerUrl;
}, 2800); // 稍微早于meta refresh的时间,确保我们的JS控制重定向
};
================================================
FILE: manifest.json
================================================
{
"name": "LibreTV",
"short_name": "LibreTV",
"description": "免费在线视频搜索与观看平台",
"start_url": ".",
"display": "standalone",
"background_color": "#0f1622",
"theme_color": "#000000",
"apple-mobile-web-app-capable": "yes",
"apple-mobile-web-app-status-bar-style": "black",
"icons": [
{
"src": "image/logo-black.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
================================================
FILE: middleware.js
================================================
import { sha256 } from './js/sha256.js'; // 需新建或引入SHA-256实现
// Vercel Middleware to inject environment variables
export default async function middleware(request) {
// Get the URL from the request
const url = new URL(request.url);
// Only process HTML pages
const isHtmlPage = url.pathname.endsWith('.html') || url.pathname.endsWith('/');
if (!isHtmlPage) {
return; // Let the request pass through unchanged
}
// Fetch the original response
const response = await fetch(request);
// Check if it's an HTML response
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
return response; // Return the original response if not HTML
}
// Get the HTML content
const originalHtml = await response.text();
// Replace the placeholder with actual environment variable
// If PASSWORD is not set, replace with empty string
const password = process.env.PASSWORD || '';
let passwordHash = '';
if (password) {
passwordHash = await sha256(password);
}
// 替换密码占位符
let modifiedHtml = originalHtml.replace(
'window.__ENV__.PASSWORD = "{{PASSWORD}}";',
`window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash`
);
// 修复Response构造
return new Response(modifiedHtml, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}
export const config = {
matcher: ['/', '/((?!api|_next/static|_vercel|favicon.ico).*)'],
};
================================================
FILE: netlify/edge-functions/inject-env.js
================================================
// Netlify Edge Function to inject environment variables into HTML
export default async (request, context) => {
const url = new URL(request.url);
// Only process HTML pages
const isHtmlPage = url.pathname.endsWith('.html') || url.pathname === '/';
if (!isHtmlPage) {
return; // Let the request pass through unchanged
}
// Get the original response
const response = await context.next();
// Check if it's an HTML response
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
return response; // Return the original response if not HTML
}
// Get the HTML content
const originalHtml = await response.text();
// Simple SHA-256 implementation for Netlify Edge Functions
async function sha256(message) {
const msgUint8 = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Replace the placeholder with actual environment variable
const password = Netlify.env.get('PASSWORD') || '';
let passwordHash = '';
if (password) {
passwordHash = await sha256(password);
}
const modifiedHtml = originalHtml.replace(
'window.__ENV__.PASSWORD = "{{PASSWORD}}";',
`window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash`
);
// Create a new response with the modified HTML
return new Response(modifiedHtml, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
};
export const config = {
path: ["/*"]
};
================================================
FILE: netlify/functions/proxy.mjs
================================================
// /netlify/functions/proxy.mjs - Netlify Function (ES Module)
import fetch from 'node-fetch';
import { URL } from 'url'; // Use Node.js built-in URL
import crypto from 'crypto'; // 导入 crypto 模块用于密码哈希
// --- Configuration (Read from Environment Variables) ---
const DEBUG_ENABLED = process.env.DEBUG === 'true';
const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // Default 24 hours
const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // Default 5 levels
// --- User Agent Handling ---
let USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
];
try {
const agentsJsonString = process.env.USER_AGENTS_JSON;
if (agentsJsonString) {
const parsedAgents = JSON.parse(agentsJsonString);
if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {
USER_AGENTS = parsedAgents;
console.log(`[Proxy Log Netlify] Loaded ${USER_AGENTS.length} user agents from environment variable.`);
} else {
console.warn("[Proxy Log Netlify] USER_AGENTS_JSON environment variable is not a valid non-empty array, using default.");
}
} else {
console.log("[Proxy Log Netlify] USER_AGENTS_JSON environment variable not set, using default user agents.");
}
} catch (e) {
console.error(`[Proxy Log Netlify] Error parsing USER_AGENTS_JSON environment variable: ${e.message}. Using default user agents.`);
}
const FILTER_DISCONTINUITY = false; // Ad filtering disabled
// --- Helper Functions (Same as Vercel version, except rewriteUrlToProxy) ---
function logDebug(message) {
if (DEBUG_ENABLED) {
console.log(`[Proxy Log Netlify] ${message}`);
}
}
function getTargetUrlFromPath(encodedPath) {
if (!encodedPath) { logDebug("getTargetUrlFromPath received empty path."); return null; }
try {
const decodedUrl = decodeURIComponent(encodedPath);
if (decodedUrl.match(/^https?:\/\/.+/i)) { return decodedUrl; }
else {
logDebug(`Invalid decoded URL format: ${decodedUrl}`);
if (encodedPath.match(/^https?:\/\/.+/i)) { logDebug(`Warning: Path was not encoded but looks like URL: ${encodedPath}`); return encodedPath; }
return null;
}
} catch (e) { logDebug(`Error decoding target URL: ${encodedPath} - ${e.message}`); return null; }
}
function getBaseUrl(urlStr) {
if (!urlStr) return '';
try {
const parsedUrl = new URL(urlStr);
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; }
pathSegments.pop(); return `${parsedUrl.origin}/${pathSegments.join('/')}/`;
} catch (e) {
logDebug(`Getting BaseUrl failed for "${urlStr}": ${e.message}`);
const lastSlashIndex = urlStr.lastIndexOf('/');
if (lastSlashIndex > urlStr.indexOf('://') + 2) { return urlStr.substring(0, lastSlashIndex + 1); }
return urlStr + '/';
}
}
function resolveUrl(baseUrl, relativeUrl) {
if (!relativeUrl) return ''; if (relativeUrl.match(/^https?:\/\/.+/i)) { return relativeUrl; } if (!baseUrl) return relativeUrl;
try { return new URL(relativeUrl, baseUrl).toString(); }
catch (e) {
logDebug(`URL resolution failed: base="${baseUrl}", relative="${relativeUrl}". Error: ${e.message}`);
if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } }
else { return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; }
}
}
// ** MODIFIED for Netlify redirect **
function rewriteUrlToProxy(targetUrl) {
if (!targetUrl || typeof targetUrl !== 'string') return '';
// Use the path defined in netlify.toml 'from' field
return `/proxy/${encodeURIComponent(targetUrl)}`;
}
function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; }
/**
* 验证代理请求的鉴权
*/
function validateAuth(event) {
const params = new URLSearchParams(event.queryStringParameters || {});
const authHash = params.get('auth');
const timestamp = params.get('t');
// 获取服务器端密码哈希
const serverPassword = process.env.PASSWORD;
if (!serverPassword) {
console.error('服务器未设置 PASSWORD 环境变量,代理访问被拒绝');
return false;
}
// 使用 crypto 模块计算 SHA-256 哈希
const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex');
if (!authHash || authHash !== serverPasswordHash) {
console.warn('代理请求鉴权失败:密码哈希不匹配');
return false;
}
// 验证时间戳(10分钟有效期)
if (timestamp) {
const now = Date.now();
const maxAge = 10 * 60 * 1000; // 10分钟
if (now - parseInt(timestamp) > maxAge) {
console.warn('代理请求鉴权失败:时间戳过期');
return false;
}
}
return true;
}
async function fetchContentWithType(targetUrl, requestHeaders) {
const headers = {
'User-Agent': getRandomUserAgent(),
'Accept': requestHeaders['accept'] || '*/*',
'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': requestHeaders['referer'] || new URL(targetUrl).origin,
};
Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {});
logDebug(`Fetching target: ${targetUrl} with headers: ${JSON.stringify(headers)}`);
try {
const response = await fetch(targetUrl, { headers, redirect: 'follow' });
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
logDebug(`Fetch failed: ${response.status} ${response.statusText} - ${targetUrl}`);
const err = new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`);
err.status = response.status; throw err;
}
const content = await response.text();
const contentType = response.headers.get('content-type') || '';
logDebug(`Fetch success: ${targetUrl}, Content-Type: ${contentType}, Length: ${content.length}`);
return { content, contentType, responseHeaders: response.headers };
} catch (error) {
logDebug(`Fetch exception for ${targetUrl}: ${error.message}`);
throw new Error(`Failed to fetch target URL ${targetUrl}: ${error.message}`);
}
}
function isM3u8Content(content, contentType) {
if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; }
return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');
}
function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing KEY URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); }
function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing MAP URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); }
function processMediaPlaylist(url, content) {
const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`Could not determine base URL for media playlist: ${url}. Cannot process relative paths.`); }
const lines = content.split('\n'); const output = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim(); if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue;
if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; }
if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; }
if (line.startsWith('#EXTINF')) { output.push(line); continue; }
if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`Rewriting media segment: Original='${line}', Resolved='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; }
output.push(line);
} return output.join('\n');
}
async function processM3u8Content(targetUrl, content, recursionDepth = 0) {
if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`Detected master playlist: ${targetUrl} (Depth: ${recursionDepth})`); return await processMasterPlaylist(targetUrl, content, recursionDepth); }
logDebug(`Detected media playlist: ${targetUrl} (Depth: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content);
}
async function processMasterPlaylist(url, content, recursionDepth) {
if (recursionDepth > MAX_RECURSION) { throw new Error(`Max recursion depth (${MAX_RECURSION}) exceeded for master playlist: ${url}`); }
const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = '';
for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/); const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0; let variantUriLine = ''; for (let j = i + 1; j < lines.length; j++) { const line = lines[j].trim(); if (line && !line.startsWith('#')) { variantUriLine = line; i = j; break; } } if (variantUriLine && currentBandwidth >= highestBandwidth) { highestBandwidth = currentBandwidth; bestVariantUrl = resolveUrl(baseUrl, variantUriLine); } } }
if (!bestVariantUrl) { logDebug(`No BANDWIDTH found, trying first URI in: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) { bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`Fallback: Found first sub-playlist URI: ${bestVariantUrl}`); break; } } }
if (!bestVariantUrl) { logDebug(`No valid sub-playlist URI found in master: ${url}. Processing as media playlist.`); return processMediaPlaylist(url, content); }
logDebug(`Selected sub-playlist (Bandwidth: ${highestBandwidth}): ${bestVariantUrl}`);
const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {});
if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`Fetched sub-playlist ${bestVariantUrl} is not M3U8 (Type: ${variantContentType}). Treating as media playlist.`); return processMediaPlaylist(bestVariantUrl, variantContent); }
return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1);
}
// --- Netlify Handler ---
export const handler = async (event, context) => {
console.log('--- Netlify Proxy Request ---');
console.log('Time:', new Date().toISOString());
console.log('Method:', event.httpMethod);
console.log('Path:', event.path);
// Note: event.queryStringParameters contains query params if any
// Note: event.headers contains incoming headers
// --- CORS Headers (for all responses) ---
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': '*', // Allow all headers client might send
};
// --- Handle OPTIONS Preflight Request ---
if (event.httpMethod === 'OPTIONS') {
logDebug("Handling OPTIONS request");
return {
statusCode: 204,
headers: {
...corsHeaders,
'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
},
body: '',
};
}
// --- 验证鉴权 ---
if (!validateAuth(event)) {
console.warn('Netlify 代理请求鉴权失败');
return {
statusCode: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
error: '代理访问未授权:请检查密码配置或鉴权参数'
}),
};
}
// --- Extract Target URL ---
// Based on netlify.toml rewrite: from = "/proxy/*" to = "/.netlify/functions/proxy/:splat"
// The :splat part should be available in event.path after the base path
let encodedUrlPath = '';
const proxyPrefix = '/proxy/'; // Match the 'from' path in netlify.toml
if (event.path && event.path.startsWith(proxyPrefix)) {
encodedUrlPath = event.path.substring(proxyPrefix.length);
logDebug(`Extracted encoded path from event.path: ${encodedUrlPath}`);
} else {
logDebug(`Could not extract encoded path from event.path: ${event.path}`);
// Potentially handle direct calls too? Less likely needed.
// const functionPath = '/.netlify/functions/proxy/';
// if (event.path && event.path.startsWith(functionPath)) {
// encodedUrlPath = event.path.substring(functionPath.length);
// }
}
const targetUrl = getTargetUrlFromPath(encodedUrlPath);
logDebug(`Resolved target URL: ${targetUrl || 'null'}`);
if (!targetUrl) {
logDebug('Error: Invalid proxy request path.');
return {
statusCode: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, error: "Invalid proxy request path. Could not extract target URL." }),
};
}
logDebug(`Processing proxy request for target: ${targetUrl}`);
try {
// 验证鉴权
const isValidAuth = validateAuth(event);
if (!isValidAuth) {
return {
statusCode: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, error: "Forbidden: Invalid auth credentials." }),
};
}
// Fetch Original Content (Pass Netlify event headers)
const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, event.headers);
// --- Process if M3U8 ---
if (isM3u8Content(content, contentType)) {
logDebug(`Processing M3U8 content: ${targetUrl}`);
const processedM3u8 = await processM3u8Content(targetUrl, content);
logDebug(`Successfully processed M3U8 for ${targetUrl}`);
return {
statusCode: 200,
headers: {
...corsHeaders, // Include CORS headers
'Content-Type': 'application/vnd.apple.mpegurl;charset=utf-8',
'Cache-Control': `public, max-age=${CACHE_TTL}`,
// Note: Do NOT include content-encoding or content-length from original response
// as node-fetch likely decompressed it and length changed.
},
body: processedM3u8, // Netlify expects body as string
};
} else {
// --- Return Original Content (Non-M3U8) ---
logDebug(`Returning non-M3U8 content directly: ${targetUrl}, Type: ${contentType}`);
// Prepare headers for Netlify response object
const netlifyHeaders = { ...corsHeaders };
responseHeaders.forEach((value, key) => {
const lowerKey = key.toLowerCase();
// Exclude problematic headers and CORS headers (already added)
if (!lowerKey.startsWith('access-control-') &&
lowerKey !== 'content-encoding' &&
lowerKey !== 'content-length') {
netlifyHeaders[key] = value; // Add other original headers
}
});
netlifyHeaders['Cache-Control'] = `public, max-age=${CACHE_TTL}`; // Set our cache policy
return {
statusCode: 200,
headers: netlifyHeaders,
body: content, // Body as string
// isBase64Encoded: false, // Set true only if returning binary data as base64
};
}
} catch (error) {
logDebug(`ERROR in proxy processing for ${targetUrl}: ${error.message}`);
console.error(`[Proxy Error Stack Netlify] ${error.stack}`); // Log full stack
const statusCode = error.status || 500; // Get status from error if available
return {
statusCode: statusCode,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
error: `Proxy processing error: ${error.message}`,
targetUrl: targetUrl
}),
};
}
};
================================================
FILE: netlify.toml
================================================
# netlify.toml
[build]
# 如果你的项目不需要构建步骤 (纯静态 + functions),可以省略 publish
# publish = "." # 假设你的 HTML/CSS/JS 文件在根目录
functions = "netlify/functions" # 指定 Netlify 函数目录
# 配置 Edge Functions
[[edge_functions]]
function = "inject-env"
path = "/*"
# 配置重写规则,将 /proxy/* 的请求路由到 proxy 函数
# 这样前端的 PROXY_URL 仍然可以是 '/proxy/'
[[redirects]]
from = "/proxy/*"
to = "/.netlify/functions/proxy/:splat" # 将路径参数传递给函数
status = 200 # 重要:这是代理,不是重定向
# 处理搜索路径格式 /s=*
[[redirects]]
from = "/s=*"
to = "/index.html"
status = 200
# (可选)为其他静态文件设置缓存头等
# [[headers]]
# for = "/*"
# [headers.values]
# # Add any global headers here
================================================
FILE: nodemon.json
================================================
{
"watch": [
"server.mjs",
"*.html",
".env"
],
"ext": "js,mjs,json,html,css",
"ignore": [
"node_modules/**/*",
".git/**/*"
],
"delay": "500",
"env": {
"NODE_ENV": "development"
},
"execMap": {
"mjs": "node"
},
"verbose": true,
"restartable": "rs"
}
================================================
FILE: package.json
================================================
{
"name": "libretv",
"type": "module",
"version": "1.1.0",
"private": true,
"description": "免费在线视频搜索与观看平台",
"author": "bestZwei",
"license": "Apache-2.0",
"scripts": {
"dev": "nodemon server.mjs",
"start": "node server.mjs"
},
"dependencies": {
"axios": "^1.9.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}
================================================
FILE: player.html
================================================
LibreTV 播放器
================================================
FILE: render.yaml
================================================
services:
- type: web
name: libretv
runtime: node
plan: free
buildCommand: 'npm install'
startCommand: 'node server.mjs'
autoDeploy: true
================================================
FILE: robots.txt
================================================
User-agent: *
Disallow: /
================================================
FILE: server.mjs
================================================
import path from 'path';
import express from 'express';
import axios from 'axios';
import cors from 'cors';
import { fileURLToPath } from 'url';
import fs from 'fs';
import crypto from 'crypto';
import dotenv from 'dotenv';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const config = {
port: process.env.PORT || 8080,
password: process.env.PASSWORD || '',
corsOrigin: process.env.CORS_ORIGIN || '*',
timeout: parseInt(process.env.REQUEST_TIMEOUT || '5000'),
maxRetries: parseInt(process.env.MAX_RETRIES || '2'),
cacheMaxAge: process.env.CACHE_MAX_AGE || '1d',
userAgent: process.env.USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
debug: process.env.DEBUG === 'true'
};
const log = (...args) => {
if (config.debug) {
console.log('[DEBUG]', ...args);
}
};
const app = express();
app.use(cors({
origin: config.corsOrigin,
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('X-XSS-Protection', '1; mode=block');
next();
});
function sha256Hash(input) {
return new Promise((resolve) => {
const hash = crypto.createHash('sha256');
hash.update(input);
resolve(hash.digest('hex'));
});
}
async function renderPage(filePath, password) {
let content = fs.readFileSync(filePath, 'utf8');
if (password !== '') {
const sha256 = await sha256Hash(password);
content = content.replace('{{PASSWORD}}', sha256);
} else {
content = content.replace('{{PASSWORD}}', '');
}
return content;
}
app.get(['/', '/index.html', '/player.html'], async (req, res) => {
try {
let filePath;
switch (req.path) {
case '/player.html':
filePath = path.join(__dirname, 'player.html');
break;
default: // '/' 和 '/index.html'
filePath = path.join(__dirname, 'index.html');
break;
}
const content = await renderPage(filePath, config.password);
res.send(content);
} catch (error) {
console.error('页面渲染错误:', error);
res.status(500).send('读取静态页面失败');
}
});
app.get('/s=:keyword', async (req, res) => {
try {
const filePath = path.join(__dirname, 'index.html');
const content = await renderPage(filePath, config.password);
res.send(content);
} catch (error) {
console.error('搜索页面渲染错误:', error);
res.status(500).send('读取静态页面失败');
}
});
function isValidUrl(urlString) {
try {
const parsed = new URL(urlString);
const allowedProtocols = ['http:', 'https:'];
// 从环境变量获取阻止的主机名列表
const blockedHostnames = (process.env.BLOCKED_HOSTS || 'localhost,127.0.0.1,0.0.0.0,::1').split(',');
// 从环境变量获取阻止的 IP 前缀
const blockedPrefixes = (process.env.BLOCKED_IP_PREFIXES || '192.168.,10.,172.').split(',');
if (!allowedProtocols.includes(parsed.protocol)) return false;
if (blockedHostnames.includes(parsed.hostname)) return false;
for (const prefix of blockedPrefixes) {
if (parsed.hostname.startsWith(prefix)) return false;
}
return true;
} catch {
return false;
}
}
// 验证代理请求的鉴权
function validateProxyAuth(req) {
const authHash = req.query.auth;
const timestamp = req.query.t;
// 获取服务器端密码哈希
const serverPassword = config.password;
if (!serverPassword) {
console.error('服务器未设置 PASSWORD 环境变量,代理访问被拒绝');
return false;
}
// 使用 crypto 模块计算 SHA-256 哈希
const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex');
if (!authHash || authHash !== serverPasswordHash) {
console.warn('代理请求鉴权失败:密码哈希不匹配');
console.warn(`期望: ${serverPasswordHash}, 收到: ${authHash}`);
return false;
}
// 验证时间戳(10分钟有效期)
if (timestamp) {
const now = Date.now();
const maxAge = 10 * 60 * 1000; // 10分钟
if (now - parseInt(timestamp) > maxAge) {
console.warn('代理请求鉴权失败:时间戳过期');
return false;
}
}
return true;
}
app.get('/proxy/:encodedUrl', async (req, res) => {
try {
// 验证鉴权
if (!validateProxyAuth(req)) {
return res.status(401).json({
success: false,
error: '代理访问未授权:请检查密码配置或鉴权参数'
});
}
const encodedUrl = req.params.encodedUrl;
const targetUrl = decodeURIComponent(encodedUrl);
// 安全验证
if (!isValidUrl(targetUrl)) {
return res.status(400).send('无效的 URL');
}
log(`代理请求: ${targetUrl}`);
// 添加请求超时和重试逻辑
const maxRetries = config.maxRetries;
let retries = 0;
const makeRequest = async () => {
try {
return await axios({
method: 'get',
url: targetUrl,
responseType: 'stream',
timeout: config.timeout,
headers: {
'User-Agent': config.userAgent
}
});
} catch (error) {
if (retries < maxRetries) {
retries++;
log(`重试请求 (${retries}/${maxRetries}): ${targetUrl}`);
return makeRequest();
}
throw error;
}
};
const response = await makeRequest();
// 转发响应头(过滤敏感头)
const headers = { ...response.headers };
const sensitiveHeaders = (
process.env.FILTERED_HEADERS ||
'content-security-policy,cookie,set-cookie,x-frame-options,access-control-allow-origin'
).split(',');
sensitiveHeaders.forEach(header => delete headers[header]);
res.set(headers);
// 管道传输响应流
response.data.pipe(res);
} catch (error) {
console.error('代理请求错误:', error.message);
if (error.response) {
res.status(error.response.status || 500);
error.response.data.pipe(res);
} else {
res.status(500).send(`请求失败: ${error.message}`);
}
}
});
app.use(express.static(path.join(__dirname), {
maxAge: config.cacheMaxAge
}));
app.use((err, req, res, next) => {
console.error('服务器错误:', err);
res.status(500).send('服务器内部错误');
});
app.use((req, res) => {
res.status(404).send('页面未找到');
});
// 启动服务器
app.listen(config.port, () => {
console.log(`服务器运行在 http://localhost:${config.port}`);
if (config.password !== '') {
console.log('用户登录密码已设置');
} else {
console.log('警告: 未设置 PASSWORD 环境变量,用户将被要求设置密码');
}
if (config.debug) {
console.log('调试模式已启用');
console.log('配置:', { ...config, password: config.password ? '******' : '' });
}
});
================================================
FILE: service-worker.js
================================================
// 不使用缓存,直接通过网络获取资源
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
================================================
FILE: vercel.json
================================================
{
"rewrites": [
{
"source": "/proxy/:path*",
"destination": "/api/proxy/:path*"
},
{
"source": "/s=:query",
"destination": "/index.html"
},
{
"source": "/player.html",
"destination": "/player.html"
},
{
"source": "/player.html/:path*",
"destination": "/player.html"
},
{
"source": "/:path*",
"destination": "/:path*"
}
]
}
================================================
FILE: watch.html
================================================
正在跳转到播放器...
正在加载播放器...
准备视频数据中,请稍候...
如果页面没有自动跳转,请点击这里