651 lines
23 KiB
HTML
Executable File
651 lines
23 KiB
HTML
Executable File
<!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>
|