Toos upload /.github/Toos/静态页/智能播放列表工具箱/index.htm

This commit is contained in:
cluntop
2026-02-25 17:56:36 +08:00
parent 7048b9ad89
commit 10ae7b82b7
@@ -0,0 +1,650 @@
<!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>