Files
tvbox-1/.github/Toos/静态页/智能播放列表工具箱/index.htm
T
2026-02-25 22:39:46 +08:00

651 lines
23 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>智能播放列表工具箱</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!-- 图标库 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>
:root {
--primary: #28a745;
--primary-dark: #218838;
--primary-light: #e8f5e8;
--background: #f5f8fa;
--card-bg: #fff;
--radius: 20px;
--shadow: 0 6px 32px #22bfa73d;
--border: 1.5px solid #e5f2ee;
--font-main: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
}
body {
background: var(--background);
font-family: var(--font-main);
color: #1a3224;
margin: 0;
letter-spacing: .01em;
}
.main-wrap {
max-width: 940px;
margin: 56px auto 0 auto;
padding: 0 18px;
}
.brand-header {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin-bottom: 40px;
}
.brand-logo {
width: 54px; height: 54px;
background: linear-gradient(135deg, #24c6dc 0, #28a745 100%);
color: #fff; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 2.2em; box-shadow: 0 3px 16px #28a74530;
}
.brand-title {
font-size: 2.1em;
font-weight: 800;
background: linear-gradient(90deg, #24c6dc 10%, #28a745 90%);
color: transparent;
background-clip: text;
-webkit-background-clip: text;
letter-spacing: .08em;
}
.tab-header {
display: flex;
border-radius: var(--radius) var(--radius) 0 0;
overflow: hidden;
margin-bottom: 0;
box-shadow: var(--shadow);
background: #f8fcf7;
}
.tab-btn {
flex: 1;
font-size: 19px;
padding: 18px 0 14px 0;
background: #f7fdfb;
color: var(--primary);
font-weight: 700;
border: none;
cursor: pointer;
transition: all .21s cubic-bezier(.4,0,.2,1);
outline: none;
border-bottom: 4px solid transparent;
border-radius: 32px 32px 0 0;
box-shadow: 0 2px 8px #24c6dc13;
letter-spacing: 0.5px;
position: relative;
}
.tab-btn .fa-solid { margin-right: 9px; }
.tab-btn.active {
background: linear-gradient(90deg,#24c6dc 0,#28a745 100%);
color: #fff;
border-bottom: 4px solid #1ea864;
box-shadow: 0 8px 32px #24c6dc37;
}
.tab-btn:not(.active):hover {
background: #eafbe6;
color: #1fa764;
}
.tab-content {
display: none;
background: var(--card-bg);
border-radius: 0 0 var(--radius) var(--radius);
box-shadow: var(--shadow);
padding: 38px 38px 34px 38px;
margin-top: 0;
min-height: 540px;
animation: fadein .23s;
}
.tab-content.active { display: block; }
@keyframes fadein { from {opacity: 0;transform: translateY(18px);} to {opacity: 1;transform: none;} }
@media (max-width: 700px) {
.main-wrap {padding: 0 1vw;}
.tab-content {padding: 16px 3vw 18px 3vw;}
.brand-title {font-size: 1.4em;}
.brand-logo {width: 40px;height:40px;font-size: 1.4em;}
}
/* 卡片/分块样式 */
.info, .desc, .warning, .example, .upload-type-option, .url-examples {
border-radius: var(--radius);
box-shadow: 0 1px 10px #24c6dc10;
background: var(--primary-light);
border: none;
}
.info, .desc {background: #eafbe6;}
.warning {background: #fff9d6; border-left: 4px solid #ffba00;}
.example, .url-examples {background: #f8f9fa;}
.upload-type-selector {display: flex; gap: 13px; margin-bottom: 22px;}
.upload-type-option {
flex: 1; padding: 15px 8px 13px 8px;
background: #f7fdfb;
border: var(--border);
border-radius: var(--radius);
cursor: pointer;
user-select: none;
transition: all .18s;
text-align: center;
font-size: 16px;
color: var(--primary);
min-width: 120px;
margin-bottom: 0;
}
.upload-type-option i {font-size: 1.2em; margin-right: 6px;}
.upload-type-option:hover, .upload-type-option.active {
background: linear-gradient(90deg,#f4fffd 60%,#e8f5e8 100%);
border: 2px solid var(--primary);
color: #119946;
font-weight: bold;
box-shadow: 0 2px 10px #24c6dc15;
}
.upload-type-option input[type="radio"] {display: none;}
.upload-section {
display: none;
padding: 20px 12px;
border-radius: var(--radius);
background: #f7fdfb;
margin-bottom: 18px;
border: var(--border);
box-shadow: 0 1px 8px #24c6dc11;
}
.upload-section.active {display: block;}
.form-group {margin-bottom: 22px;}
label {display: block;margin-bottom: 7px;font-weight: bold;color: #399058;letter-spacing:0.15px;}
input[type="file"], input[type="url"], textarea, select {
width: 100%;padding: 12px 14px; border-radius: 11px;
border: var(--border);
background: #f7fdfb;
font-size: 16px;
margin-top: 3px;
margin-bottom: 13px;
transition: all .18s;
box-shadow: 0 1px 6px #24c6dc10;
}
input[type="file"]:focus, input[type="url"]:focus, textarea:focus, select:focus {
outline: none; border-color: var(--primary); background: #f1faf4;
}
button, .merge-container button {
width: 100%;
padding: 14px 0;
background: linear-gradient(90deg,#24c6dc 0,#28a745 100%);
color: #fff;
border: none;
border-radius: 16px;
font-size: 18px;
font-weight: 700;
box-shadow: 0 2px 12px #24c6dc19;
margin-top: 16px;
letter-spacing: 0.7px;
cursor: pointer;
transition: .2s cubic-bezier(.6,0,.2,1);
display: flex; align-items: center; justify-content: center; gap: 10px;
}
button:active, .merge-container button:active {
transform: scale(0.98);
box-shadow: 0 0 0 #fff0;
}
button:hover, .merge-container button:hover {
filter: brightness(1.09) drop-shadow(0 0 8px #24c6dc3d);
}
pre {
background: #f8f9fa;
border-radius: 12px;
border-left: 4px solid #28a745;
padding: 16px 12px;
font-size: 14px;
margin-top: 11px;
overflow-x: auto;
}
hr { border: none; border-top: 1px solid #e6efeb; margin: 24px 0;}
.format-type {color: #24c6dc;}
.highlight {background: #fff3cd; padding: 2px 6px; border-radius: 6px;}
small {color: #9e9e9e;}
</style>
</head>
<body>
<div class="main-wrap">
<!-- 顶部品牌LOGO+标题 -->
<div class="brand-header">
<div class="brand-logo"><i class="fa-solid fa-shuffle"></i></div>
<span class="brand-title">智能播放列表工具箱</span>
</div>
<!-- Tab头部 -->
<div class="tab-header">
<button class="tab-btn active" data-tab="convert"><i class="fa-solid fa-repeat"></i>格式转换</button>
<button class="tab-btn" data-tab="merge"><i class="fa-solid fa-layer-group"></i>合并去重</button>
</div>
<!-- 格式转换内容 -->
<div class="tab-content convert-container active" id="tab-convert">
<div style="text-align: center; margin-bottom: 22px;">
<span style="background-color: #ffeaa7; padding: 5px 16px; border-radius: 15px; font-size: 15px; color:#8b7411;">
<i class="fa-solid fa-star"></i>
支持分组、批量、远程订阅、头信息双向保留
</span>
</div>
<div class="info" style="margin-bottom: 24px;">
<strong><i class="fa-solid fa-wand-magic-sparkles"></i> 智能转换特性:</strong><br>
• 自动识别M3U和TXT格式(含/不含分组均支持)<br>
<span class="highlight">头信息/epg/自定义内容可完整保留</span><br>
• 支持本地多文件上传和远程URL订阅<br>
• 无需手动选择转换方向
</div>
<div class="upload-type-selector">
<label class="upload-type-option active">
<input type="radio" name="upload_type" value="file" checked>
<i class="fa-solid fa-upload"></i>本地批量上传<br>
<small>可多选M3U/TXT文件</small>
</label>
<label class="upload-type-option">
<input type="radio" name="upload_type" value="url">
<i class="fa-solid fa-link"></i>远程订阅链接<br>
<small>输入在线播放列表URL</small>
</label>
</div>
<div id="file_section" class="upload-section active">
<div class="form-group">
<label for="fileInput"><i class="fa-solid fa-file-arrow-up"></i>选择文件 (M3U 或 TXT,可多选):</label>
<input type="file" id="fileInput" accept=".m3u,.txt,.m3u8" multiple>
</div>
</div>
<div id="url_section" class="upload-section">
<div class="form-group">
<label for="remoteUrl"><i class="fa-solid fa-globe"></i>远程订阅链接:</label>
<input type="url" id="remoteUrl" placeholder="https://example.com/playlist.m3u">
<div class="url-examples">
<strong>支持的链接格式示例:</strong><br>
• http://example.com/iptv.m3u<br>
• https://example.com/channels.txt<br>
• http://example.com/playlist.m3u8<br>
• 支持重定向和HTTPS链接
</div>
</div>
</div>
<button type="button" id="convertBtn"><i class="fa-solid fa-bolt"></i>智能转换</button>
<div class="example">
<strong>转换示例 (支持分组)</strong><br><br>
<span class="format-type">📺 M3U格式 → TXT格式:</span><br>
<strong>输入:</strong><br>
#EXTM3U<br>
#EXTM3U x-tvg-url="https://epg.xx/epg.xml"<br>
#EXTINF:-1 group-title="卫视",湖南卫视<br>
http://example.com/hunan.m3u8<br><br>
<strong>输出:</strong><br>
<span class="highlight">#EXTM3U<br>#EXTM3U x-tvg-url="https://epg.xx/epg.xml"</span><br>
<span class="highlight">卫视,#genre#</span><br>
湖南卫视,http://example.com/hunan.m3u8<br><br>
<hr style="margin: 20px 0; border: 1px solid #ddd;">
<span class="format-type">📝 TXT格式 → M3U格式:</span><br>
<strong>输入:</strong><br>
<span class="highlight">#EXTM3U<br>#EXTM3U x-tvg-url="https://epg.xx/epg.xml"</span><br>
<span class="highlight">卫视,#genre#</span><br>
湖南卫视,http://example.com/hunan.m3u8<br><br>
<strong>输出:</strong><br>
#EXTM3U<br>
#EXTM3U x-tvg-url="https://epg.xx/epg.xml"<br>
#EXTINF:-1 tvg-id="湖南卫视" tvg-name="湖南卫视" tvg-logo="" group-title="卫视",湖南卫视<br>
http://example.com/hunan.m3u8
</div>
</div>
<!-- 合并去重内容 -->
<div class="tab-content merge-container" id="tab-merge">
<div class="desc" style="margin-bottom:20px;">
<b><i class="fa-solid fa-object-group"></i> 合并去重特性:</b>
<ul>
<li>支持本地多个M3U/TXT文件合并</li>
<li>支持多个远程订阅URL合并</li>
<li>频道地址相同的自动去重(保留第一个)</li>
<li>保留所有分组信息、头信息</li>
<li>可选输出格式(M3U或TXT</li>
</ul>
</div>
<div class="warning" style="margin-bottom:22px;">
<i class="fa-solid fa-triangle-exclamation"></i>
<b>去重说明:</b>频道URL相同的,只保留第一个频道分组/频道,其余相同地址频道将被忽略。
</div>
<div class="form-group">
<label for="mergeFileInput"><i class="fa-solid fa-folder-open"></i>本地文件(可多选M3U/TXT,支持批量合并):</label>
<input type="file" id="mergeFileInput" multiple accept=".m3u,.txt,.m3u8">
</div>
<div class="form-group">
<label for="mergeUrlInput"><i class="fa-solid fa-link"></i>远程订阅链接(每行一个URL,可多行):</label>
<textarea id="mergeUrlInput" rows="3" placeholder="https://example.com/playlist.m3u"></textarea>
</div>
<div class="form-group">
<label for="outputFormat"><i class="fa-solid fa-list"></i>输出格式:</label>
<select id="outputFormat">
<option value="m3u">M3U格式(.m3u)</option>
<option value="txt">TXT格式(.txt)</option>
</select>
</div>
<button id="mergeBtn"><i class="fa-solid fa-circle-nodes"></i>合并并去重导出</button>
<div id="resultSection" style="display:none;">
<b>合并去重后预览:</b>
<pre id="resultPreview"></pre>
</div>
<div class="example" style="margin-top:28px;">
<b>合并去重示例:</b>
<pre>
🔗 多文件合并 + 按URL去重:
文件1 (playlist1.m3u):
#EXTM3U
#EXTINF:-1 group-title="央视",CCTV1
http://example.com/cctv1.m3u8
#EXTINF:-1 group-title="央视",CCTV2
http://example.com/cctv2.m3u8
文件2 (channels.txt):
央视,#genre#
CCTV1高清,http://example.com/cctv1.m3u8 ← 与文件1的CCTV1相同URL
CCTV3,http://example.com/cctv3.m3u8
卫视,#genre#
湖南卫视,http://example.com/hnws.m3u8
远程URL订阅:
http://yang-1989.eu.org/playlist.m3u
https://yang-1989.eu.org/channels.txt
合并后输出 (TXT格式,按URL去重):
# 合并完成 - 共 2 个分组,4 个频道(已按URL去重)
央视,#genre#
CCTV1,http://example.com/cctv1.m3u8 ← 保留第一个
CCTV2,http://example.com/cctv2.m3u8
CCTV3,http://example.com/cctv3.m3u8
卫视,#genre#
湖南卫视,http://example.com/hnws.m3u8
⚠️ 说明:CCTV1高清虽然名称不同,但URL相同,所以被去重了,只保留了第一个遇到的CCTV1
</pre>
</div>
</div>
</div>
<script>
// Tab切换
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.onclick = function() {
document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
this.classList.add('active');
document.getElementById('tab-' + this.dataset.tab).classList.add('active');
};
});
// m3u转txt
function m3u2txt(m3u) {
let lines = m3u.split(/\r?\n/);
let output = [];
let customHeaders = [];
let currentGroup = "";
let firstGroupWritten = false;
for(let i=0; i<lines.length; i++){
let line = lines[i].trim();
if(line.startsWith('#') && !line.startsWith('#EXTINF')) {
customHeaders.push(line);
} else if(line.startsWith('#EXTINF')){
let groupMatch = line.match(/group-title="([^"]+)"/);
let nameMatch = line.split(',').pop().trim();
if(groupMatch){
if(groupMatch[1] !== currentGroup){
currentGroup = groupMatch[1];
output.push(currentGroup+',#genre#');
}
} else {
if(!firstGroupWritten){
currentGroup = "未分组";
output.push(currentGroup+',#genre#');
firstGroupWritten = true;
}
}
output.push(nameMatch+',');
} else if(line.startsWith('http')){
let prev = output.pop();
output.push(prev + line);
}
}
return (customHeaders.length ? customHeaders.join('\n')+'\n' : '') + output.join('\n');
}
// txt转m3u(支持无分组)
function txt2m3u(txt){
let lines = txt.split(/\r?\n/);
let output = [];
let group = '';
// 保留头部所有 # 行
for(let i=0;i<lines.length;i++){
let line = lines[i].trim();
if(line.startsWith('#')) output.push(line);
}
if(!output.length) output.push('#EXTM3U');
for(let i=0; i<lines.length; i++){
let line = lines[i].trim();
if(!line || line.startsWith('#')) continue;
if(line.endsWith(',#genre#')){
group = line.replace(',#genre#','');
} else {
let arr = line.split(',');
if(arr.length===2){
let name = arr[0].trim();
let url = arr[1].trim();
output.push(`#EXTINF:-1 tvg-id="${name}" tvg-name="${name}" tvg-logo="" group-title="${group}",${name}`);
output.push(url);
}
}
}
return output.join('\n');
}
// 自动识别格式
function autoConvert(content, filename) {
if(/<html|<!DOCTYPE html/i.test(content)) return null;
content = content.replace(/^\uFEFF/, '').trim();
let lines = content.split(/\r?\n/).map(l=>l.trim()).filter(Boolean);
if(lines.length === 0) return null;
let first = lines[0];
if(first.toUpperCase().startsWith('#EXTM3U')){
let result = m3u2txt(lines.join('\n'));
let outName = filename.replace(/\.(m3u8?|txt)$/i, '') + '.txt';
if(result && result.trim().length > 0) {
return {result, outName};
} else {
return null;
}
}
// 关键补丁:只要有频道名,URL就识别为TXT
if (
lines.some(l => l.endsWith(',#genre#')) ||
lines.some(l => l.includes(',') && !l.startsWith('#'))
) {
let result = txt2m3u(lines.join('\n'));
let outName = filename.replace(/\.(m3u8?|txt)$/i, '') + '.m3u';
if(result && result.trim().length > 0) {
return {result, outName};
} else {
return null;
}
}
return null;
}
function downloadStr(str, filename){
let blob = new Blob([str], {type: 'text/plain'});
let a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// 上传方式切换
const typeRadios = document.querySelectorAll('input[name="upload_type"]');
const uploadSections = document.querySelectorAll('.upload-section');
const typeOptions = document.querySelectorAll('.upload-type-option');
typeRadios.forEach((radio, idx) => {
radio.addEventListener('change', function() {
typeOptions.forEach(option => option.classList.remove('active'));
typeOptions[idx].classList.add('active');
uploadSections.forEach(section => section.classList.remove('active'));
document.getElementById(this.value + '_section').classList.add('active');
});
});
typeOptions.forEach(option => {
option.addEventListener('click', function() {
let radio = this.querySelector('input[type="radio"]');
if (radio) {
radio.checked = true;
radio.dispatchEvent(new Event('change'));
}
});
});
document.getElementById('convertBtn').onclick = async function(){
const isFile = document.querySelector('input[name="upload_type"]:checked').value === 'file';
if(isFile){
const fileInput = document.getElementById('fileInput');
if(!fileInput.files.length){
alert('请先选择文件');
return;
}
for(const file of fileInput.files){
const content = await file.text();
const conv = autoConvert(content, file.name);
if(!conv){
alert(`文件 ${file.name} 格式无法识别,跳过`);
continue;
}
downloadStr(conv.result, conv.outName);
}
} else {
const url = document.getElementById('remoteUrl').value.trim();
if(!url){
alert('请输入有效的URL');
return;
}
try{
const resp = await fetch(url);
if(!resp.ok) throw new Error("获取失败");
const content = await resp.text();
let fileName = url.split('/').pop().split('?')[0] || 'remote.m3u';
const conv = autoConvert(content, fileName);
if(!conv){
alert("远程内容格式无法识别!(如需排查请联系开发者)");
return;
}
downloadStr(conv.result, conv.outName);
}catch(e){
alert("远程链接获取失败:" + e.message);
}
}
};
// 代码2(合并去重)逻辑保持不变
function parseM3UorTXT(raw) {
let lines = raw.split(/\r?\n/).map(l=>l.trim());
let headers = [], channels = [];
let group = '', currentGroup = '', firstGroupWritten = false;
for(let i=0;i<lines.length;i++){
let line = lines[i];
if(line.startsWith('#') && !line.startsWith('#EXTINF')) {
headers.push(line);
} else if(line.startsWith('#EXTINF')) {
let groupMatch = line.match(/group-title="([^"]+)"/);
let nameMatch = line.split(',').pop().trim();
if(groupMatch){
group = groupMatch[1];
} else {
if(!firstGroupWritten){ group = "未分组"; firstGroupWritten = true;}
}
channels.push({name: nameMatch, group, extinf: line});
} else if(line.startsWith('http')){
if(channels.length && !channels[channels.length-1].url)
channels[channels.length-1].url = line;
} else if(line.endsWith(',#genre#')) {
currentGroup = line.replace(',#genre#','');
} else if(line && line.includes(',')) {
let arr = line.split(',');
if(arr.length==2){
channels.push({name: arr[0].trim(), url: arr[1].trim(), group: currentGroup});
}
}
}
return {headers, channels};
}
function exportM3U(headers, channels) {
let out = [];
out.push(...headers.length ? headers : ['#EXTM3U']);
channels.forEach(ch => {
let g = ch.group || '';
let extinf = ch.extinf || `#EXTINF:-1 tvg-id="${ch.name}" tvg-name="${ch.name}" tvg-logo="" group-title="${g}",${ch.name}`;
out.push(extinf);
out.push(ch.url);
});
return out.join('\n');
}
function exportTXT(headers, channels) {
let out = [];
let groupSet = new Set();
channels.forEach(ch=>groupSet.add(ch.group));
out.push(`# 合并完成 - 共 ${groupSet.size} 个分组,${channels.length} 个频道(已按URL去重)`);
out.push(...headers);
let lastGroup = '';
channels.forEach(ch => {
let g = ch.group || '';
if(g !== lastGroup) {
out.push(g + ',#genre#');
lastGroup = g;
}
out.push(ch.name + ',' + ch.url);
});
return out.join('\n');
}
function mergeAndDedup(allData) {
let headers = allData.find(x=>x.headers.length)?.headers || ['#EXTM3U'];
let urlSet = new Set(), channels = [];
allData.forEach(({channels:chs})=>{
chs.forEach(ch=>{
if(ch.url && !urlSet.has(ch.url)){
urlSet.add(ch.url);
channels.push(ch);
}
});
});
return {headers, channels};
}
document.getElementById('mergeBtn').onclick = async function(){
let fileInput = document.getElementById('mergeFileInput');
let urlInput = document.getElementById('mergeUrlInput').value.trim();
let outputFormat = document.getElementById('outputFormat').value;
let allData = [];
if(fileInput.files.length){
for(let file of fileInput.files){
let txt = await file.text();
allData.push(parseM3UorTXT(txt));
}
}
let urls = urlInput.split('\n').map(l=>l.trim()).filter(Boolean);
for(let url of urls){
try{
let res = await fetch(url);
if(res.ok){
let txt = await res.text();
allData.push(parseM3UorTXT(txt));
}
}catch(e){}
}
if(!allData.length){
alert("请至少上传一个文件或输入一个有效订阅URL");
return;
}
let {headers, channels} = mergeAndDedup(allData);
let result = (outputFormat === "m3u") ? exportM3U(headers, channels) : exportTXT(headers, channels);
document.getElementById('resultSection').style.display = "block";
document.getElementById('resultPreview').textContent = result.slice(0, 4000) + (result.length > 4000 ? '\n...(预览已省略)...' : '');
let blob = new Blob([result], {type: "text/plain"});
let fname = "playlist_merged." + (outputFormat === "m3u" ? "m3u" : "txt");
let a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
</script>
</body>
</html>