#!/usr/bin/env python3
from flask import Flask, request, render_template_string, jsonify
import subprocess
import os
import re
from pathlib import Path
import threading
import time
app = Flask(__name__)
# ===== 設定區 =====
MIDI_PORT = "24:0"
MIDI_FOLDER = Path.home() / "midi"
_port_file = Path.home() / "sc8850_port.txt"
if _port_file.exists():
_p = _port_file.read_text().strip()
if _p:
MIDI_PORT = _p
# ==================
current_process = None
current_playing_path = ""
current_playing_name = ""
play_queue = []
queue_lock = threading.Lock()
# ----- 多國語言字串 -----
I18N = {
"zh": {
"title": "SC-8850 KTV 點歌台",
"subtitle": "埠 {port} · 點歌排隊 · 分類選歌",
"now_title": "正在熱唱中",
"skip": "切歌",
"tab_songs": "點歌目錄",
"tab_queue": "已點清單",
"search_ph": "搜尋曲名或分類…",
"upload_to": "上傳至:",
"root": "根目錄 (~/midi)",
"new_folder": "新增資料夾",
"add_selected": "勾選加入清單",
"select_all": "全選/取消",
"upload": "上傳(插播)",
"stop_clear": "停止並清空",
"redetect": "重新偵測 MIDI",
"lib_title": "全部曲庫",
"cat_all": "全部分類",
"cat_label": "分類:",
"queue_title": "等待播放佇列",
"clear_queue": "清空已點清單",
"refresh": "重新整理",
"empty_lib": "~/midi 尚無 MIDI 檔",
"empty_queue": "目前沒有排隊,趕快去點歌吧!",
"request": "點歌",
"priority": "插播",
"remove": "退歌",
"modal_title": "新增音樂資料夾",
"modal_ph": "例如:anime、gs",
"cancel": "取消",
"create": "建立",
"confirm_skip": None,
"confirm_stop": "停止播放並清空全部佇列?",
"confirm_clear": "確定清空所有已點清單?",
"confirm_redetect": "將停止播放並重新偵測 SC-8850,確定?",
"need_select": "請先勾選要點播的歌曲",
"ok_queue": "已加入點歌佇列",
"ok_batch": "已加入排隊清單",
"ok_upload": "已上傳(插播)",
"ok_skip": "已切歌",
"ok_stop": "已停止並清空佇列",
"ok_clear": "已清空排隊清單",
"ok_priority": "已設為下一首優先",
"fail": "操作失敗",
"songs_count": "{n} 首",
"match_count": "符合 {v} / 共 {t} 首",
"queue_count": "{n} 首",
},
"ja": {
"title": "SC-8850 KTV 選曲システム",
"subtitle": "ポート {port} · 順番待ち · カテゴリ選曲",
"now_title": "演奏中",
"skip": "スキップ",
"tab_songs": "曲一覧",
"tab_queue": "予約リスト",
"search_ph": "曲名やカテゴリを検索…",
"upload_to": "アップロード先:",
"root": "ルート (~/midi)",
"new_folder": "フォルダ追加",
"add_selected": "選択を追加",
"select_all": "全選択/解除",
"upload": "アップロード(割込)",
"stop_clear": "停止してクリア",
"redetect": "MIDI再検出",
"lib_title": "全曲ライブラリ",
"cat_all": "すべてのカテゴリ",
"cat_label": "カテゴリ:",
"queue_title": "再生待ちキュー",
"clear_queue": "予約をクリア",
"refresh": "更新",
"empty_lib": "~/midi に MIDI がありません",
"empty_queue": "予約はありません。曲を選んでください!",
"request": "予約",
"priority": "優先",
"remove": "削除",
"modal_title": "フォルダを追加",
"modal_ph": "例:anime、gs",
"cancel": "キャンセル",
"create": "作成",
"confirm_stop": "再生を停止し、キューをすべてクリアしますか?",
"confirm_clear": "予約リストをすべてクリアしますか?",
"confirm_redetect": "再生を停止し SC-8850 を再検出します。よろしいですか?",
"need_select": "先に曲を選択してください",
"ok_queue": "予約キューに追加しました",
"ok_batch": "キューに追加しました",
"ok_upload": "アップロード完了(割込)",
"ok_skip": "スキップしました",
"ok_stop": "停止してクリアしました",
"ok_clear": "キューをクリアしました",
"ok_priority": "次曲を優先に設定しました",
"fail": "失敗しました",
"songs_count": "{n} 曲",
"match_count": "一致 {v} / 全 {t} 曲",
"queue_count": "{n} 曲",
},
"en": {
"title": "SC-8850 KTV Song Request",
"subtitle": "Port {port} · Queue · Browse by category",
"now_title": "Now Playing",
"skip": "Skip",
"tab_songs": "Library",
"tab_queue": "Queue",
"search_ph": "Search title or category…",
"upload_to": "Upload to:",
"root": "Root (~/midi)",
"new_folder": "New folder",
"add_selected": "Add selected",
"select_all": "Select all/none",
"upload": "Upload (insert)",
"stop_clear": "Stop & clear",
"redetect": "Re-detect MIDI",
"lib_title": "Song library",
"cat_all": "All categories",
"cat_label": "Category:",
"queue_title": "Play queue",
"clear_queue": "Clear queue",
"refresh": "Refresh",
"empty_lib": "No MIDI files in ~/midi",
"empty_queue": "Queue is empty. Request a song!",
"request": "Request",
"priority": "Priority",
"remove": "Remove",
"modal_title": "New folder",
"modal_ph": "e.g. anime, gs",
"cancel": "Cancel",
"create": "Create",
"confirm_stop": "Stop playback and clear the entire queue?",
"confirm_clear": "Clear all queued songs?",
"confirm_redetect": "Stop playback and re-detect SC-8850?",
"need_select": "Please select songs first",
"ok_queue": "Added to queue",
"ok_batch": "Added to queue",
"ok_upload": "Uploaded (insert next)",
"ok_skip": "Skipped",
"ok_stop": "Stopped and cleared",
"ok_clear": "Queue cleared",
"ok_priority": "Set as next priority",
"fail": "Failed",
"songs_count": "{n} songs",
"match_count": "{v} / {t} songs",
"queue_count": "{n} songs",
},
}
def safe_name(name: str) -> str:
name = os.path.basename(name)
name = re.sub(r'[^\w\u4e00-\u9fff\-_. ()]', '_', name)
return name.strip() or "unnamed"
def get_rel_name(path_str: str) -> str:
try:
return Path(path_str).relative_to(MIDI_FOLDER).as_posix()
except ValueError:
return Path(path_str).name
def is_safe_midi_path(path_str: str) -> bool:
if not path_str or not os.path.isfile(path_str):
return False
try:
Path(path_str).resolve().relative_to(MIDI_FOLDER.resolve())
return True
except ValueError:
return False
def find_midi_files():
files = []
if not MIDI_FOLDER.exists():
return files
for p in MIDI_FOLDER.rglob("*"):
if p.is_file() and p.suffix.lower() in (".mid", ".midi"):
rel = p.relative_to(MIDI_FOLDER)
# category = 第一層資料夾,根目錄為 ""
parts = rel.parts
category = parts[0] if len(parts) > 1 else ""
files.append({
"path": str(p),
"name": str(rel),
"category": category,
})
files.sort(key=lambda x: x["name"].lower())
return files
def list_dirs():
dirs = ["."]
if not MIDI_FOLDER.exists():
return dirs
for p in MIDI_FOLDER.rglob("*"):
if p.is_dir():
rel = str(p.relative_to(MIDI_FOLDER))
if rel and rel != ".":
dirs.append(rel)
dirs.sort()
return dirs
def list_categories():
"""第一層子目錄作為分類"""
cats = []
if not MIDI_FOLDER.exists():
return cats
for p in MIDI_FOLDER.iterdir():
if p.is_dir():
cats.append(p.name)
cats.sort(key=str.lower)
return cats
def detect_midi_port():
global MIDI_PORT
try:
out = subprocess.check_output(
["aplaymidi", "-l"], text=True, stderr=subprocess.DEVNULL
)
except Exception as e:
return False, f"aplaymidi error: {e}"
priority, others = [], []
for line in out.splitlines():
line_lower = line.lower()
if "client" in line_lower and "port" in line_lower:
continue
parts = line.split()
if not parts or not re.match(r"^\d+:\d+$", parts[0]):
continue
port = parts[0]
if "through" in line_lower or "system" in line_lower:
continue
if any(k in line_lower for k in ["8850", "sc-8850", "sc8850", "roland", "edirol"]):
priority.append(port)
else:
others.append(port)
candidates = priority + others
if not candidates:
return False, "SC-8850 / MIDI port not found"
port = candidates[0]
MIDI_PORT = port
try:
(Path.home() / "sc8850_port.txt").write_text(port + "\n")
except Exception:
pass
return True, f"MIDI port: {port}"
def _start_play_unlocked(path: str):
global current_process, current_playing_path, current_playing_name
subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)
current_playing_path = path
current_playing_name = get_rel_name(path)
current_process = subprocess.Popen(["aplaymidi", "-p", MIDI_PORT, path])
def _queue_or_play_unlocked(path: str):
global current_process, play_queue
if current_process is None or current_process.poll() is not None:
if os.path.isfile(path):
_start_play_unlocked(path)
else:
if path not in play_queue:
play_queue.append(path)
def monitor_playback():
global current_process, current_playing_path, current_playing_name, play_queue
while True:
time.sleep(1)
with queue_lock:
if current_process is not None:
ret = current_process.poll()
if ret is not None:
current_process = None
current_playing_path = ""
current_playing_name = ""
if play_queue:
next_path = play_queue.pop(0)
if os.path.isfile(next_path):
_start_play_unlocked(next_path)
threading.Thread(target=monitor_playback, daemon=True).start()
def play_next_in_queue_or_start(target_path: str):
if not is_safe_midi_path(target_path):
return False
with queue_lock:
_queue_or_play_unlocked(target_path)
return True
def skip_current():
global current_process, current_playing_path, current_playing_name
with queue_lock:
subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)
current_process = None
current_playing_path = ""
current_playing_name = ""
if play_queue:
next_path = play_queue.pop(0)
if os.path.isfile(next_path):
_start_play_unlocked(next_path)
def stop_midi():
global current_process, current_playing_path, current_playing_name, play_queue
with queue_lock:
subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)
current_process = None
current_playing_path = ""
current_playing_name = ""
play_queue.clear()
HTML = r"""
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SC-8850 KTV</title>
<style>
:root {
--bg: #0f1117; --card: #1a1d27; --border: #2a2e3a;
--text: #e8eaed; --muted: #9aa0a6;
--accent: #f43f5e; --accent-hover: #e11d48;
--success: #34d399; --danger: #f87171;
--warning: #fbbf24; --info: #38bdf8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans TC", "Noto Sans JP", sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; padding: 20px 14px 48px; line-height: 1.5;
}
.container { max-width: 720px; margin: 0 auto; }
header { text-align: center; margin-bottom: 12px; }
header h1 {
font-size: 1.6rem; font-weight: 700;
background: linear-gradient(135deg, #fb7185, #38bdf8);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
header p { color: var(--muted); font-size: 0.88rem; margin-top: 4px; }
.lang-bar {
display: flex; justify-content: center; gap: 6px; margin-bottom: 14px;
}
.lang-btn {
padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--card); color: var(--muted); font-size: 0.82rem;
font-weight: 600; cursor: pointer;
}
.lang-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.now-playing-box {
background: linear-gradient(135deg, rgba(244,63,94,0.15), rgba(56,189,248,0.15));
border: 1px solid var(--accent); border-radius: 14px;
padding: 14px 18px; margin-bottom: 14px;
display: none; align-items: center; justify-content: space-between; gap: 12px;
}
.np-info { display: flex; align-items: center; gap: 12px; min-width: 0; }
.np-icon { font-size: 1.7rem; flex-shrink: 0; }
.np-title { font-size: 0.78rem; color: var(--muted); }
.np-song { font-weight: 700; font-size: 1.02rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--success); }
.tabs {
display: flex; gap: 8px; margin-bottom: 14px;
background: var(--card); padding: 4px; border-radius: 12px; border: 1px solid var(--border);
}
.tab-btn {
flex: 1; padding: 10px; border: none; background: transparent; color: var(--muted);
font-weight: 600; font-size: 0.92rem; border-radius: 8px; cursor: pointer;
}
.tab-btn.active { background: var(--accent); color: white; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.search-box { margin-bottom: 12px; position: relative; }
.search-input {
width: 100%; background: var(--card); border: 1px solid var(--border);
color: var(--text); border-radius: 12px; padding: 12px 16px 12px 40px;
font-size: 0.95rem; outline: none;
}
.search-input:focus { border-color: var(--accent); }
.search-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--muted); pointer-events: none;
}
.panel {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 12px 14px; margin-bottom: 12px; display: flex; flex-wrap: wrap;
gap: 10px; align-items: center;
}
.panel select {
background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 8px 10px; font-size: 0.88rem;
}
.panel label { color: var(--muted); font-size: 0.85rem; }
.actions, .actions-secondary {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;
}
.btn {
flex: 1; min-width: 88px; padding: 10px 12px; border: none; border-radius: 10px;
font-size: 0.85rem; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 4px;
}
.btn-primary { background: var(--accent); color: white; }
.btn-success { background: var(--success); color: #000; }
.btn-danger { background: rgba(248,113,113,0.15); color: var(--danger); border: 1px solid rgba(248,113,113,0.3); }
.btn-warning { background: rgba(251,191,36,0.15); color: var(--warning); border: 1px solid rgba(251,191,36,0.3); }
.btn-secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
.btn-info { background: rgba(56,189,248,0.15); color: var(--info); border: 1px solid rgba(56,189,248,0.3); }
.status {
display: none; padding: 12px 14px; border-radius: 10px; margin-bottom: 12px; font-size: 0.9rem;
background: rgba(52,211,153,0.12); border: 1px solid rgba(52,211,153,0.3); color: var(--success);
}
.status.error { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.3); color: var(--danger); }
.section-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
}
.section-header h2 { font-size: 1.02rem; font-weight: 600; }
.section-header span { color: var(--muted); font-size: 0.85rem; }
.file-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
.file-item {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 10px 14px; display: flex; align-items: center; gap: 10px;
}
.file-item.playing { border-color: var(--success); background: rgba(52,211,153,0.08); }
.file-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
.file-details { min-width: 0; flex: 1; }
.file-name { font-weight: 500; font-size: 0.94rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-path { font-size: 0.76rem; color: var(--muted); margin-top: 2px; }
.ktv-btn {
background: var(--accent); color: white; border: none; border-radius: 8px;
padding: 6px 12px; font-size: 0.8rem; font-weight: 600; cursor: pointer; flex-shrink: 0;
}
.empty {
text-align: center; padding: 32px 16px; color: var(--muted);
background: var(--card); border-radius: 12px; border: 1px solid var(--border);
}
input[type="file"] { display: none; }
input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
.modal-bg {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100;
align-items: center; justify-content: center; padding: 16px;
}
.modal-bg.show { display: flex; }
.modal {
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
padding: 22px; width: 100%; max-width: 360px;
}
.modal h3 { margin-bottom: 12px; font-size: 1.05rem; }
.modal input {
width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; outline: none;
}
.modal-actions { display: flex; gap: 10px; }
.modal-actions .btn { flex: 1; }
</style>
</head>
<body>
<div class="container">
<header>
<h1 id="uiTitle">SC-8850 KTV</h1>
<p id="uiSubtitle">—</p>
</header>
<div class="lang-bar">
<button type="button" class="lang-btn active" data-lang="zh" onclick="setLang('zh')">繁中</button>
<button type="button" class="lang-btn" data-lang="ja" onclick="setLang('ja')">日本語</button>
<button type="button" class="lang-btn" data-lang="en" onclick="setLang('en')">English</button>
</div>
<div class="now-playing-box" id="npBox">
<div class="np-info">
<div class="np-icon">🎤</div>
<div>
<div class="np-title" id="npTitle">Now</div>
<div class="np-song" id="npSong">-</div>
</div>
</div>
<button class="btn btn-warning" style="flex:0;padding:6px 12px;font-size:0.8rem;" id="btnSkipTop" onclick="skipPlay()">Skip</button>
</div>
<div class="tabs">
<button class="tab-btn active" id="tabBtnSongs" onclick="switchTab('songs')">Library</button>
<button class="tab-btn" id="tabBtnQueue" onclick="switchTab('queue')">Queue (0)</button>
</div>
<div class="status" id="status"></div>
<div class="tab-content active" id="tabSongs">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" class="search-input" id="searchInput" oninput="filterList()">
</div>
<div class="panel">
<label id="lblCat">Category:</label>
<select id="catFilter" onchange="filterList()" style="flex:1;min-width:120px;"></select>
</div>
<div class="panel">
<label id="lblUpload">Upload to:</label>
<select id="uploadDir" style="flex:1;min-width:110px;"></select>
<button class="btn btn-secondary" style="flex:0;min-width:auto;padding:8px 10px;font-size:0.8rem;" id="btnNewDir" onclick="showNewDir()">+</button>
</div>
<div class="actions">
<button class="btn btn-success" id="btnAddSel" onclick="queueSelected()">Add</button>
<button class="btn btn-secondary" id="btnSelAll" onclick="toggleSelectAll()">Select</button>
<button class="btn btn-primary" id="btnUpload" onclick="document.getElementById('fileInput').click()">Upload</button>
</div>
<div class="actions-secondary">
<button class="btn btn-warning" id="btnSkip" onclick="skipPlay()">Skip</button>
<button class="btn btn-danger" id="btnStop" onclick="stopPlay()">Stop</button>
<button class="btn btn-info" id="btnRedetect" onclick="redetectMidi()">MIDI</button>
</div>
<div class="section-header">
<h2 id="libTitle">Library</h2>
<span id="count"></span>
</div>
<ul class="file-list" id="fileList"></ul>
</div>
<div class="tab-content" id="tabQueue">
<div class="actions">
<button class="btn btn-danger" id="btnClearQ" onclick="clearQueue()">Clear</button>
<button class="btn btn-secondary" id="btnRefresh" onclick="pollStatus()">Refresh</button>
</div>
<div class="section-header">
<h2 id="queueTitle">Queue</h2>
<span id="queueCount">0</span>
</div>
<ul class="file-list" id="queueList"></ul>
</div>
<input type="file" id="fileInput" accept=".mid,.midi" multiple>
</div>
<div class="modal-bg" id="dirModal">
<div class="modal">
<h3 id="modalTitle">New folder</h3>
<input type="text" id="newDirName" maxlength="40">
<div class="modal-actions">
<button class="btn btn-secondary" id="btnCancel" onclick="hideNewDir()">Cancel</button>
<button class="btn btn-primary" id="btnCreate" onclick="createDir()">Create</button>
</div>
</div>
</div>
<script>
const I18N = __I18N_JSON__;
let lang = localStorage.getItem('kmidi_lang') || 'zh';
let allFiles = [];
let categories = [];
let currentPlaying = "";
let currentQueue = [];
let midiPort = "-";
const $ = id => document.getElementById(id);
function t(key, vars) {
let s = (I18N[lang] && I18N[lang][key]) || (I18N.en[key]) || key;
if (vars) {
Object.keys(vars).forEach(k => { s = s.replace('{' + k + '}', vars[k]); });
}
return s;
}
function setLang(code) {
lang = code;
localStorage.setItem('kmidi_lang', code);
document.querySelectorAll('.lang-btn').forEach(b => {
b.classList.toggle('active', b.dataset.lang === code);
});
applyI18n();
filterList();
pollStatus();
}
function applyI18n() {
document.title = t('title');
$('uiTitle').textContent = t('title');
$('uiSubtitle').textContent = t('subtitle', { port: midiPort });
$('npTitle').textContent = t('now_title');
$('btnSkipTop').textContent = '⏭ ' + t('skip');
$('tabBtnSongs').textContent = '🎵 ' + t('tab_songs');
$('searchInput').placeholder = t('search_ph');
$('lblCat').textContent = t('cat_label');
$('lblUpload').textContent = t('upload_to');
$('btnNewDir').textContent = '+ ' + t('new_folder');
$('btnAddSel').textContent = '➕ ' + t('add_selected');
$('btnSelAll').textContent = '☑ ' + t('select_all');
$('btnUpload').textContent = '📤 ' + t('upload');
$('btnSkip').textContent = '⏭ ' + t('skip');
$('btnStop').textContent = '⏹ ' + t('stop_clear');
$('btnRedetect').textContent = '🔌 ' + t('redetect');
$('libTitle').textContent = t('lib_title');
$('btnClearQ').textContent = '🗑 ' + t('clear_queue');
$('btnRefresh').textContent = '🔄 ' + t('refresh');
$('queueTitle').textContent = t('queue_title');
$('modalTitle').textContent = t('modal_title');
$('newDirName').placeholder = t('modal_ph');
$('btnCancel').textContent = t('cancel');
$('btnCreate').textContent = t('create');
fillCatSelect();
fillUploadDirs();
renderFileList();
}
function showStatus(msg, isError) {
const el = $('status');
el.style.display = 'block';
el.textContent = msg;
el.className = 'status' + (isError ? ' error' : '');
clearTimeout(window._st);
window._st = setTimeout(() => { el.style.display = 'none'; }, 3500);
}
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
if (name === 'songs') {
$('tabBtnSongs').classList.add('active');
$('tabSongs').classList.add('active');
} else {
$('tabBtnQueue').classList.add('active');
$('tabQueue').classList.add('active');
}
}
function fillCatSelect() {
const sel = $('catFilter');
const cur = sel.value;
sel.innerHTML = '';
const optAll = document.createElement('option');
optAll.value = '';
optAll.textContent = t('cat_all');
sel.appendChild(optAll);
categories.forEach(c => {
const o = document.createElement('option');
o.value = c;
o.textContent = c;
sel.appendChild(o);
});
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
}
function fillUploadDirs() {
fetch('/dirs').then(r => r.json()).then(dirs => {
const sel = $('uploadDir');
const cur = sel.value;
sel.innerHTML = '';
dirs.forEach(d => {
const o = document.createElement('option');
o.value = d;
o.textContent = d === '.' ? t('root') : d;
sel.appendChild(o);
});
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
});
}
$('fileInput').addEventListener('change', () => {
if ($('fileInput').files.length) uploadFiles($('fileInput').files);
$('fileInput').value = '';
});
async function uploadFiles(list) {
const dir = $('uploadDir').value || '.';
let ok = 0;
for (const file of list) {
const fd = new FormData();
fd.append('midi', file);
fd.append('dir', dir);
try {
const res = await fetch('/upload', { method: 'POST', body: fd });
if (res.ok) ok++;
} catch (e) {}
}
await loadList();
pollStatus();
if (ok) showStatus(t('ok_upload') + ` (${ok})`);
}
async function queueSingle(path) {
try {
const res = await fetch('/queue/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
showStatus(res.ok ? t('ok_queue') : await res.text(), !res.ok);
pollStatus();
} catch (e) { showStatus(t('fail'), true); }
}
async function queueSelected() {
const boxes = document.querySelectorAll('#fileList .file-checkbox:checked');
if (!boxes.length) { showStatus(t('need_select'), true); return; }
const paths = Array.from(boxes).map(cb => cb.dataset.path);
try {
const res = await fetch('/queue/add_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths })
});
showStatus(res.ok ? t('ok_batch') : await res.text(), !res.ok);
boxes.forEach(cb => { cb.checked = false; });
pollStatus();
} catch (e) { showStatus(t('fail'), true); }
}
async function priorityQueue(index) {
await fetch('/queue/priority', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index })
});
showStatus(t('ok_priority'));
pollStatus();
}
async function removeQueue(index) {
await fetch('/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index })
});
pollStatus();
}
async function clearQueue() {
if (!confirm(t('confirm_clear'))) return;
await fetch('/queue/clear', { method: 'POST' });
showStatus(t('ok_clear'));
pollStatus();
}
async function skipPlay() {
const res = await fetch('/skip', { method: 'POST' });
showStatus(res.ok ? t('ok_skip') : t('fail'), !res.ok);
pollStatus();
}
async function stopPlay() {
if (!confirm(t('confirm_stop'))) return;
await fetch('/stop', { method: 'POST' });
showStatus(t('ok_stop'));
pollStatus();
}
async function redetectMidi() {
if (!confirm(t('confirm_redetect'))) return;
const res = await fetch('/redetect_midi', { method: 'POST' });
const text = await res.text();
showStatus(text, !res.ok);
pollStatus();
}
function toggleSelectAll() {
const boxes = document.querySelectorAll('#fileList .file-item:not([style*="display: none"]) .file-checkbox');
if (!boxes.length) return;
const all = Array.from(boxes).every(cb => cb.checked);
boxes.forEach(cb => { cb.checked = !all; });
}
function filterList() {
const keyword = $('searchInput').value.toLowerCase().trim();
const cat = $('catFilter').value;
const items = document.querySelectorAll('#fileList .file-item');
let visible = 0;
items.forEach(item => {
const name = (item.dataset.name || '').toLowerCase();
const itemCat = item.dataset.category || '';
const okCat = !cat || itemCat === cat;
const okKey = !keyword || name.includes(keyword);
if (okCat && okKey) {
item.style.display = '';
visible++;
} else {
item.style.display = 'none';
}
});
if (keyword || cat) {
$('count').textContent = t('match_count', { v: visible, t: allFiles.length });
} else {
$('count').textContent = allFiles.length ? t('songs_count', { n: allFiles.length }) : '';
}
}
function renderFileList() {
const ul = $('fileList');
ul.innerHTML = '';
if (!allFiles.length) {
ul.innerHTML = `<li class="empty">${t('empty_lib')}</li>`;
$('count').textContent = '';
return;
}
allFiles.forEach(f => {
const parts = f.name.split('/');
const name = parts.pop();
const folder = parts.length ? parts.join('/') + '/' : '';
const li = document.createElement('li');
li.className = 'file-item' + (f.name === currentPlaying ? ' playing' : '');
li.dataset.name = f.name;
li.dataset.category = f.category || '';
li.innerHTML = `
<div class="file-info">
<input type="checkbox" class="file-checkbox">
<div class="file-details">
<div class="file-name">${name}</div>
${folder ? `<div class="file-path">${folder}</div>` : ''}
</div>
</div>
<button class="ktv-btn" type="button">${t('request')}</button>
`;
li.querySelector('.file-checkbox').dataset.path = f.path;
li.querySelector('.ktv-btn').onclick = () => queueSingle(f.path);
ul.appendChild(li);
});
filterList();
}
async function pollStatus() {
try {
const res = await fetch('/status');
const data = await res.json();
currentPlaying = data.playing_name || '';
currentQueue = data.queue || [];
midiPort = data.midi_port || '-';
$('uiSubtitle').textContent = t('subtitle', { port: midiPort });
if (currentPlaying) {
$('npBox').style.display = 'flex';
$('npSong').textContent = currentPlaying;
} else {
$('npBox').style.display = 'none';
}
$('tabBtnQueue').textContent = `📜 ${t('tab_queue')} (${currentQueue.length})`;
$('queueCount').textContent = t('queue_count', { n: currentQueue.length });
const ql = $('queueList');
if (!currentQueue.length) {
ql.innerHTML = `<li class="empty">${t('empty_queue')}</li>`;
} else {
ql.innerHTML = '';
currentQueue.forEach((q, idx) => {
const li = document.createElement('li');
li.className = 'file-item';
li.innerHTML = `
<div style="font-weight:700;color:var(--accent);min-width:24px;">#${idx + 1}</div>
<div class="file-info">
<div class="file-details">
<div class="file-name">${q.name}</div>
<div class="file-path">${q.folder || ''}</div>
</div>
</div>
<button class="btn btn-warning" style="flex:0;padding:6px 10px;font-size:0.76rem;" type="button">${t('priority')}</button>
<button class="btn btn-danger" style="flex:0;padding:6px 10px;font-size:0.76rem;" type="button">${t('remove')}</button>
`;
const btns = li.querySelectorAll('button');
btns[0].onclick = () => priorityQueue(idx);
btns[1].onclick = () => removeQueue(idx);
ql.appendChild(li);
});
}
document.querySelectorAll('#fileList .file-item').forEach(item => {
item.classList.toggle('playing', item.dataset.name === currentPlaying);
});
} catch (e) {}
}
async function loadList() {
const res = await fetch('/list');
allFiles = await res.json();
const catRes = await fetch('/categories');
categories = await catRes.json();
fillCatSelect();
renderFileList();
pollStatus();
}
function showNewDir() {
$('dirModal').classList.add('show');
$('newDirName').value = '';
$('newDirName').focus();
}
function hideNewDir() { $('dirModal').classList.remove('show'); }
async function createDir() {
const name = $('newDirName').value.trim();
if (!name) return;
const res = await fetch('/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const text = await res.text();
showStatus(text, !res.ok);
if (res.ok) {
hideNewDir();
await loadList();
fillUploadDirs();
$('uploadDir').value = name;
}
}
setLang(lang);
loadList();
fillUploadDirs();
setInterval(pollStatus, 2000);
</script>
</body>
</html>
"""
# 注入多國語言 JSON 到前端
HTML = HTML.replace("__I18N_JSON__", json_dumps_i18n())
def json_dumps_i18n():
import json
return json.dumps(I18N, ensure_ascii=False)
# 修正:上面 replace 在定義前會錯,改為函式產生 HTML
def get_html():
import json
return HTML.replace("__I18N_JSON__", "null") if False else render_html()
def render_html():
import json
raw = open_html_template()
return raw
# 簡化:直接在路由用 replace
_HTML_TEMPLATE = None
def build_html():
import json
# 重新取得含占位符的模板(模組載入時 HTML 已含 __I18N_JSON__)
return HTML.replace("__I18N_JSON__", json.dumps(I18N, ensure_ascii=False))
@app.route("/")
def index():
return build_html()
@app.route("/list")
def list_files():
return jsonify(find_midi_files())
@app.route("/dirs")
def dirs():
return jsonify(list_dirs())
@app.route("/categories")
def categories():
return jsonify(list_categories())
@app.route("/status")
def status():
with queue_lock:
queue_data = []
for p in play_queue:
queue_data.append({
"path": p,
"name": Path(p).name,
"folder": get_rel_name(p),
})
return jsonify({
"playing_name": current_playing_name,
"queue": queue_data,
"midi_port": MIDI_PORT,
})
@app.route("/queue/add", methods=["POST"])
def queue_add():
data = request.get_json() or {}
path = data.get("path", "")
if not is_safe_midi_path(path):
return "Invalid path", 400
play_next_in_queue_or_start(path)
return "OK"
@app.route("/queue/add_batch", methods=["POST"])
def queue_add_batch():
data = request.get_json() or {}
paths = data.get("paths", [])
if not paths:
return "No files", 400
count = 0
with queue_lock:
for p in paths:
if is_safe_midi_path(p):
_queue_or_play_unlocked(p)
count += 1
return f"OK {count}"
@app.route("/queue/priority", methods=["POST"])
def queue_priority():
data = request.get_json() or {}
idx = data.get("index")
if idx is None:
return "Invalid", 400
with queue_lock:
if 0 <= idx < len(play_queue):
item = play_queue.pop(idx)
play_queue.insert(0, item)
return "OK"
@app.route("/queue/remove", methods=["POST"])
def queue_remove():
data = request.get_json() or {}
idx = data.get("index")
if idx is None:
return "Invalid", 400
with queue_lock:
if 0 <= idx < len(play_queue):
play_queue.pop(idx)
return "OK"
@app.route("/queue/clear", methods=["POST"])
def queue_clear():
with queue_lock:
play_queue.clear()
return "OK"
@app.route("/skip", methods=["POST"])
def skip():
skip_current()
return "OK"
@app.route("/stop", methods=["POST"])
def stop():
stop_midi()
return "OK"
@app.route("/redetect_midi", methods=["POST"])
def redetect_midi():
stop_midi()
time.sleep(0.3)
ok, msg = detect_midi_port()
return msg, (200 if ok else 400)
@app.route("/upload", methods=["POST"])
def upload():
f = request.files.get("midi")
if not f or not f.filename or not f.filename.lower().endswith((".mid", ".midi")):
return "Bad file", 400
sub = request.form.get("dir", ".").strip() or "."
sub = sub.replace("..", "").strip("/")
target_dir = MIDI_FOLDER if sub in ("", ".") else (MIDI_FOLDER / sub)
try:
target_dir.resolve().relative_to(MIDI_FOLDER.resolve())
except ValueError:
target_dir = MIDI_FOLDER
target_dir.mkdir(parents=True, exist_ok=True)
fname = safe_name(f.filename)
save_path = target_dir / fname
if save_path.exists():
stem, suf = save_path.stem, save_path.suffix
n = 1
while save_path.exists():
save_path = target_dir / f"{stem}_{n}{suf}"
n += 1
f.save(str(save_path))
path_str = str(save_path)
with queue_lock:
if current_process is None or current_process.poll() is not None:
_start_play_unlocked(path_str)
else:
if path_str in play_queue:
play_queue.remove(path_str)
play_queue.insert(0, path_str)
return "OK"
@app.route("/mkdir", methods=["POST"])
def mkdir():
data = request.get_json() or {}
name = safe_name(data.get("name", ""))
if not name or name in (".", ".."):
return "Invalid name", 400
path = MIDI_FOLDER / name
if path.exists():
return "Exists", 400
path.mkdir(parents=True, exist_ok=False)
return "OK"
if __name__ == "__main__":
MIDI_FOLDER.mkdir(parents=True, exist_ok=True)
ok, msg = detect_midi_port()
print(msg)
print(f"MIDI folder: {MIDI_FOLDER}")
print(f"Port: {MIDI_PORT}")
print("KTV → http://<Pi-IP>:8080")
app.run(host="0.0.0.0", port=8080, debug=False)
沒有留言:
張貼留言