短剧MP4合并器

之前看短剧,看1-2分钟就要缓冲一下,实在是受不了,干脆借助Trea加小米送的词元,做了这个合并器,反正自己测试了可以用。不排除还有bug,提供源码,可以自己去进一步完善。因为pyqt5安装挺麻烦的,所以干脆使用了PySide6,优先支持Linux国产系统,兼容Windows。若是源码运行时提示你缺少第三方库,自己安装即可。现在有AI写个软件真的太轻松了。

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
短剧 MP4 快速合并工具 v1.4.1(PySide6 便携版)
所有日志、配置均存储在程序所在目录下,无需写入系统目录。

功能:
  - 启动时检测 ffmpeg/ffprobe 是否可用
  - 子线程扫描文件信息,避免 UI 假死
  - 拖拽排序、批量导入视频文件
  - ffmpeg concat 合并,带进度反馈
  - 未捕获异常写入日志文件
  - 支持 PyInstaller 打包
  - 自然排序(1.mp4 → 2.mp4 → 10.mp4)
"""

import sys
import os
import re
import json
import queue
import traceback
import subprocess
import platform
import shutil
import tempfile
import threading
from datetime import datetime

from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QLabel, QListWidget, QListWidgetItem, QFileDialog,
    QMessageBox, QProgressBar, QAbstractItemView, QStyle,
    QGroupBox, QTextEdit, QCheckBox, QComboBox
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor, QFont, QIcon


# ──────────────────────────────────────────────
# 常量
# ──────────────────────────────────────────────
APP_NAME = "短剧 MP4 合并工具"
APP_VERSION = "1.4.1"
SUPPORTED_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".flv", ".wmv"}
MOVFLAGS_FORMATS = {".mp4", ".mov"}   # 仅这些容器需要 -movflags +faststart

IS_WINDOWS = platform.system() == "Windows"


# ──────────────────────────────────────────────
# 自然排序(Natural Sort)
# 1.mp4 → 2.mp4 → 10.mp4,而非 1.mp4 → 10.mp4 → 2.mp4
# ──────────────────────────────────────────────
def _natural_sort_key(s):
    """将字符串拆分为 [文本, 数字, 文本, 数字, ...] 用于自然排序"""
    return [int(part) if part.isdigit() else part.lower()
            for part in re.split(r'(\d+)', str(s))]


def natural_sort(paths):
    """对路径列表按文件名做自然排序"""
    return sorted(paths, key=lambda p: _natural_sort_key(os.path.basename(p)))


# ──────────────────────────────────────────────
# 便携版路径:所有数据都在程序目录下
# ──────────────────────────────────────────────
def app_base_dir():
    """程序所在目录(打包后为 exe 所在目录,开发时为 .py 所在目录)"""
    if getattr(sys, "frozen", False):
        return os.path.dirname(sys.executable)
    return os.path.dirname(os.path.abspath(__file__))


def ensure_dir(path):
    os.makedirs(path, exist_ok=True)
    return path


def log_dir():
    """日志目录:程序目录/logs/"""
    return ensure_dir(os.path.join(app_base_dir(), "logs"))


def crash_log_path():
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return os.path.join(log_dir(), f"crash_{stamp}.log")


def resource_path(*parts):
    """PyInstaller 打包资源路径,未打包时回退到程序目录"""
    if getattr(sys, "_MEIPASS", None):
        return os.path.join(sys._MEIPASS, *parts)
    return os.path.join(app_base_dir(), *parts)


# ──────────────────────────────────────────────
# 全局异常捕获 → 写日志文件
# ──────────────────────────────────────────────
def write_crash_log(exc_type, exc_value, exc_tb):
    log_path = crash_log_path()
    content = [
        f"App: {APP_NAME}",
        f"Version: {APP_VERSION}",
        f"Time: {datetime.now().isoformat()}",
        f"Platform: {platform.platform()}",
        f"Python: {sys.version}",
        "",
        "Traceback:",
        "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
    ]
    try:
        with open(log_path, "w", encoding="utf-8") as f:
            f.write("\n".join(content))
    except OSError:
        log_path = "(写入失败)"
    return log_path


def exception_hook(exc_type, exc_value, exc_tb):
    log_path = write_crash_log(exc_type, exc_value, exc_tb)
    traceback.print_exception(exc_type, exc_value, exc_tb)
    try:
        QMessageBox.critical(
            None, "程序异常",
            f"程序发生未处理异常。\n\n错误日志已写入:\n{log_path}"
        )
    except Exception:
        pass


# ──────────────────────────────────────────────
# 跨平台 subprocess 参数
# ──────────────────────────────────────────────
def subprocess_run_kwargs(timeout=None):
    """subprocess.run 的额外参数,Windows 下隐藏控制台窗口,统一 UTF-8 编码"""
    kwargs = {
        "capture_output": True,
        "text": True,
        "encoding": "utf-8",
        "errors": "replace",   # 遇到非法字节用 ? 替代,避免崩溃
    }
    if timeout is not None:
        kwargs["timeout"] = timeout
    if IS_WINDOWS:
        kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
        si = subprocess.STARTUPINFO()
        si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        kwargs["startupinfo"] = si
    return kwargs


def subprocess_popen_kwargs():
    """subprocess.Popen 的额外参数,Windows 下隐藏控制台窗口,统一 UTF-8 编码"""
    kwargs = {
        "stdout": subprocess.PIPE,
        "stderr": subprocess.PIPE,
        "text": True,
        "encoding": "utf-8",
        "errors": "replace",
    }
    if IS_WINDOWS:
        si = subprocess.STARTUPINFO()
        si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        kwargs["startupinfo"] = si
    return kwargs


# ──────────────────────────────────────────────
# ffmpeg / ffprobe 检测
# ──────────────────────────────────────────────
def check_ffmpeg():
    return shutil.which("ffmpeg") is not None


def check_ffprobe():
    return shutil.which("ffprobe") is not None


def get_install_hint():
    os_name = platform.system().lower()
    if "linux" in os_name:
        return (
            "请通过包管理器安装 FFmpeg:\n\n"
            "  Ubuntu/Debian:\n    sudo apt update && sudo apt install ffmpeg\n\n"
            "  Fedora:\n    sudo dnf install ffmpeg\n\n"
            "  Arch Linux:\n    sudo pacman -S ffmpeg\n\n"
            "  CentOS/RHEL(需 EPEL):\n    sudo yum install epel-release\n    sudo yum install ffmpeg\n"
        )
    if "windows" in os_name:
        return (
            "请安装 FFmpeg 并添加到系统 PATH:\n\n"
            "  1. 访问 https://www.gyan.dev/ffmpeg/builds/\n"
            "     下载 ffmpeg-release-essentials.zip\n\n"
            "  2. 解压到目录,例如 C:\\ffmpeg\n\n"
            "  3. 将 C:\\ffmpeg\\bin 添加到系统环境变量 PATH:\n"
            "     右键「此电脑」→ 属性 → 高级系统设置\n"
            "     → 环境变量 → 系统变量 → Path → 编辑 → 新建\n\n"
            "  4. 重启终端/应用使 PATH 生效\n"
        )
    return "请安装 FFmpeg:\n  https://ffmpeg.org/download.html\n"


def show_ffmpeg_missing_dialog(parent=None):
    hint = get_install_hint()
    msg = QMessageBox(parent)
    msg.setIcon(QMessageBox.Icon.Critical)
    msg.setWindowTitle("FFmpeg 未安装")
    msg.setText(
        "本程序依赖 FFmpeg 进行视频合并,但未检测到 ffmpeg / ffprobe 命令。\n\n"
        "请先安装 FFmpeg 后再运行本程序。"
    )
    msg.setDetailedText(hint)
    msg.setInformativeText(
        "官方文档:https://ffmpeg.org/documentation.html\n"
        "安装完成后请重新启动本程序。"
    )
    msg.setStandardButtons(QMessageBox.StandardButton.Ok)
    msg.exec()


# ──────────────────────────────────────────────
# 文件信息数据类
# ──────────────────────────────────────────────
class MediaInfo:
    def __init__(self, filepath):
        self.filepath = filepath
        self.filename = os.path.basename(filepath)
        self.duration = 0.0
        self.size_bytes = 0
        self.codec = ""
        self.resolution = ""
        self.fps = ""
        self.bitrate = ""
        self.valid = False
        self.error = ""
        self.scanning = False   # 正在扫描中

    @property
    def size_display(self):
        if self.size_bytes < 1024:
            return f"{self.size_bytes} B"
        if self.size_bytes < 1024 ** 2:
            return f"{self.size_bytes / 1024:.1f} KB"
        if self.size_bytes < 1024 ** 3:
            return f"{self.size_bytes / 1024 ** 2:.1f} MB"
        return f"{self.size_bytes / 1024 ** 3:.2f} GB"

    @property
    def duration_display(self):
        if self.duration <= 0:
            return "--:--"
        m, s = divmod(int(self.duration), 60)
        h, m = divmod(m, 60)
        if h > 0:
            return f"{h:d}:{m:02d}:{s:02d}"
        return f"{m:d}:{s:02d}"

    def summary(self):
        parts = [self.filename]
        if self.scanning:
            parts.append("[扫描中...]")
        elif self.valid:
            parts.append(self.duration_display)
            if self.resolution:
                parts.append(self.resolution)
            if self.codec:
                parts.append(self.codec)
            parts.append(self.size_display)
        elif self.error:
            parts.append(f"[错误: {self.error}]")
        return "  |  ".join(parts)


# ──────────────────────────────────────────────
# concat 路径转义(符合 ffmpeg concat demuxer 规范)
# ──────────────────────────────────────────────
def concat_escape_path(filepath):
    """
    转义路径写入 ffmpeg concat 列表。
    规则:包裹在单引号内,单引号用 '\\'' 转义。
    反斜杠不需要额外转义(-safe 0 模式下 ffmpeg 不解释反斜杠)。
    """
    return "'" + filepath.replace("'", "'\\''") + "'"


# ──────────────────────────────────────────────
# 工作线程:ffprobe 扫描
# ──────────────────────────────────────────────
class ScanWorker(QThread):
    file_scanned = Signal(str, object)   # (filepath, MediaInfo)
    scan_finished = Signal(int)

    def __init__(self, file_paths, parent=None):
        super().__init__(parent)
        self.file_paths = list(file_paths)
        self._cancelled = False

    def cancel(self):
        self._cancelled = True

    def run(self):
        processed = 0
        for filepath in self.file_paths:
            if self._cancelled:
                break
            info = self._probe_file(filepath)
            if self._cancelled:
                break
            self.file_scanned.emit(filepath, info)
            processed += 1
        self.scan_finished.emit(processed)

    @staticmethod
    def _probe_file(filepath):
        info = MediaInfo(filepath)
        try:
            info.size_bytes = os.path.getsize(filepath)
        except OSError:
            info.size_bytes = 0

        try:
            cmd = [
                "ffprobe", "-v", "quiet",
                "-print_format", "json",
                "-show_format", "-show_streams",
                filepath
            ]
            result = subprocess.run(cmd, **subprocess_run_kwargs(timeout=30))
            if result.returncode != 0:
                info.error = result.stderr.strip()[:120] or "ffprobe 返回错误"
                return info

            data = json.loads(result.stdout)
            fmt = data.get("format", {})
            info.duration = float(fmt.get("duration", 0) or 0)

            for stream in data.get("streams", []):
                if stream.get("codec_type") != "video":
                    continue
                info.codec = stream.get("codec_name", "unknown")
                w, h = stream.get("width", 0), stream.get("height", 0)
                if w and h:
                    info.resolution = f"{w}x{h}"
                r_frame_rate = str(stream.get("r_frame_rate", ""))
                if "/" in r_frame_rate:
                    num, den = r_frame_rate.split("/", 1)
                    if float(den) > 0:
                        info.fps = f"{float(num) / float(den):.2f} fps"
                break

            bit_rate = fmt.get("bit_rate")
            if bit_rate:
                info.bitrate = f"{int(bit_rate) / 1000:.0f} kbps"

            info.valid = True

        except FileNotFoundError:
            info.error = "ffprobe 未找到"
        except subprocess.TimeoutExpired:
            info.error = "扫描超时"
        except json.JSONDecodeError:
            info.error = "ffprobe 输出解析失败"
        except Exception as exc:
            info.error = str(exc)[:120]
        return info


# ──────────────────────────────────────────────
# 工作线程:ffmpeg 合并
# ──────────────────────────────────────────────
class MergeWorker(QThread):
    """
    使用 ffmpeg concat 协议合并文件。
    进度:-progress pipe:1 -nostats,从 stdout 读取 out_time_us。
    错误:stderr 通过 daemon 线程收集,避免 stdout/stderr 交替 readline 的死锁风险。
    """
    progress_updated = Signal(int)
    merge_finished = Signal(bool, str)
    log_message = Signal(str)

    def __init__(self, file_paths, output_path, reencode=False,
                 output_ext=".mp4", parent=None):
        super().__init__(parent)
        self.file_paths = list(file_paths)
        self.output_path = output_path
        self.reencode = reencode
        self.output_ext = output_ext.lower()
        self._process = None
        self._cancelled = False

    def cancel(self):
        self._cancelled = True
        if self._process and self._process.poll() is None:
            try:
                self._process.terminate()
            except Exception:
                pass

    def run(self):
        try:
            self._do_merge()
        except Exception as exc:
            self.merge_finished.emit(False, f"合并失败:{exc}")

    def _probe_duration(self, filepath):
        cmd = [
            "ffprobe", "-v", "quiet",
            "-show_entries", "format=duration",
            "-of", "default=noprint_wrappers=1:nokey=1",
            filepath
        ]
        try:
            result = subprocess.run(cmd, **subprocess_run_kwargs(timeout=15))
            if result.returncode != 0:
                return 0.0
            return float(result.stdout.strip() or 0)
        except Exception:
            return 0.0

    def _do_merge(self):
        total_duration = sum(self._probe_duration(p) for p in self.file_paths)
        self.log_message.emit(f"总时长:{total_duration:.1f} 秒")

        concat_file_path = None
        stderr_lines = []

        try:
            # 创建 concat 列表文件
            concat_file_path = self._write_concat_file()
            self.log_message.emit("concat 列表已创建")

            # 构建命令
            cmd = self._build_command(concat_file_path)
            self.log_message.emit("执行 ffmpeg...")

            # 启动进程
            self._process = subprocess.Popen(cmd, **subprocess_popen_kwargs())

            # stderr 通过 daemon 线程收集
            def _collect_stderr():
                for line in self._process.stderr:
                    stripped = line.strip()
                    if stripped:
                        stderr_lines.append(stripped)

            stderr_thread = threading.Thread(target=_collect_stderr, daemon=True)
            stderr_thread.start()

            # stdout 通过 daemon 线程写入队列,主线程用超时读取
            # 解决网络路径下 ffmpeg I/O 阻塞导致 stdout 不关闭的问题
            stdout_queue = queue.Queue()
            _EOF = object()   # 哨兵值,标记 EOF

            def _enqueue_stdout():
                for line in self._process.stdout:
                    stdout_queue.put(line)
                stdout_queue.put(_EOF)

            stdout_thread = threading.Thread(target=_enqueue_stdout, daemon=True)
            stdout_thread.start()

            # 从队列读取进度,每行最多等 30 秒
            # 超时说明 ffmpeg 可能卡在网络 I/O 上(局域网写入慢)
            # 但如果进度已接近完成(>=95%),可以容忍更长等待
            PROGRESS_LINE_TIMEOUT = 30       # 正常每行超时(秒)
            NETWORK_FLUSH_TIMEOUT = 120      # 进度接近完成时的超时(秒)
            last_pct = 0
            reached_end = False

            while True:
                if self._cancelled:
                    self._process.terminate()
                    self.merge_finished.emit(False, "用户取消操作")
                    return

                # 根据当前进度选择超时:接近完成时给更多时间等网络 flush
                timeout = (NETWORK_FLUSH_TIMEOUT if last_pct >= 95
                           else PROGRESS_LINE_TIMEOUT)

                try:
                    line = stdout_queue.get(timeout=timeout)
                except queue.Empty:
                    # 超时:ffmpeg 可能卡在网络 I/O
                    if last_pct >= 95 and self._process.poll() is not None:
                        # 进程已退出但没收到 end 信号,算成功
                        self.log_message.emit("网络写入超时,但进程已完成,检查输出文件...")
                        reached_end = True
                        break
                    elif self._process.poll() is not None:
                        # 进程已退出但进度很低,算失败
                        break
                    else:
                        # 进程还在跑,继续等
                        self.log_message.emit(f"等待 ffmpeg 响应...(当前进度 {last_pct}%)")
                        continue

                if line is _EOF:
                    break   # stdout 正常关闭

                line = line.strip()
                if not line:
                    continue

                if line.startswith("out_time_us="):
                    try:
                        us = int(line.split("=", 1)[1])
                        if total_duration > 0:
                            pct = min(int(us / 1_000_000.0 / total_duration * 100), 100)
                            last_pct = pct
                            self.progress_updated.emit(pct)
                    except (ValueError, IndexError):
                        pass
                elif line.startswith("out_time_ms="):
                    try:
                        ms = int(line.split("=", 1)[1])
                        if total_duration > 0:
                            pct = min(int(ms / 1_000_000.0 / total_duration * 100), 100)
                            last_pct = pct
                            self.progress_updated.emit(pct)
                    except (ValueError, IndexError):
                        pass
                elif line.startswith("progress=") and line.endswith("end"):
                    reached_end = True
                    self.progress_updated.emit(100)

            # 等待进程退出(给网络 flush 额外时间)
            try:
                self._process.wait(timeout=30)
            except subprocess.TimeoutExpired:
                self.log_message.emit("ffmpeg 进程退出超时,强制终止...")
                self._process.kill()
                self._process.wait(timeout=5)

            stderr_thread.join(timeout=3)

            return_code = self._process.returncode

            # 判断结果
            if self._cancelled:
                self.merge_finished.emit(False, "用户取消操作")
            elif return_code == 0 or reached_end:
                # 成功:return_code==0 正常结束,或进度已到 100%/超时但进程已退出
                self.progress_updated.emit(100)
                try:
                    output_size = os.path.getsize(self.output_path)
                    size_mb = output_size / (1024 * 1024)
                    size_info = f"文件大小:{size_mb:.1f} MB"
                except OSError:
                    size_info = "文件大小:未知(网络路径可能需要稍等)"
                self.merge_finished.emit(
                    True,
                    f"合并完成!\n输出文件:{self.output_path}\n{size_info}"
                )
            else:
                err_tail = "\n".join(stderr_lines[-20:]) if stderr_lines else "无详细错误输出"
                self.merge_finished.emit(
                    False,
                    f"ffmpeg 返回错误码 {return_code}:\n{err_tail}"
                )

        finally:
            if concat_file_path:
                try:
                    os.unlink(concat_file_path)
                except OSError:
                    pass

    def _write_concat_file(self):
        """写入 concat 列表文件,使用正确的转义规则"""
        f = tempfile.NamedTemporaryFile(
            mode="w", suffix=".txt", delete=False, encoding="utf-8", newline="\n"
        )
        try:
            for filepath in self.file_paths:
                f.write(f"file {concat_escape_path(filepath)}\n")
        finally:
            f.close()
        return f.name

    def _build_command(self, concat_file_path):
        cmd = [
            "ffmpeg", "-y", "-hide_banner",
            "-progress", "pipe:1",
            "-nostats",
            "-f", "concat", "-safe", "0",
            "-i", concat_file_path,
        ]
        if self.reencode:
            cmd.extend(["-c:v", "libx264", "-c:a", "aac"])
        else:
            cmd.extend(["-c", "copy"])

        if self.output_ext in MOVFLAGS_FORMATS:
            cmd.extend(["-movflags", "+faststart"])

        cmd.append(self.output_path)
        return cmd


# ──────────────────────────────────────────────
# 可拖拽排序的列表控件
# ──────────────────────────────────────────────
class DragDropListWidget(QListWidget):
    files_dropped = Signal(list)
    order_changed = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAcceptDrops(True)
        self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
        self.setDefaultDropAction(Qt.DropAction.MoveAction)
        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.setAlternatingRowColors(True)
        self.setMinimumHeight(200)

    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
            return
        super().dragEnterEvent(event)

    def dragMoveEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
            return
        super().dragMoveEvent(event)

    def dropEvent(self, event):
        if event.mimeData().hasUrls():
            paths = []
            for url in event.mimeData().urls():
                path = url.toLocalFile()
                if os.path.isfile(path):
                    ext = os.path.splitext(path)[1].lower()
                    if ext in SUPPORTED_EXTENSIONS:
                        paths.append(path)
                elif os.path.isdir(path):
                    dir_files = []
                    for name in os.listdir(path):
                        full = os.path.join(path, name)
                        if os.path.isfile(full):
                            ext = os.path.splitext(name)[1].lower()
                            if ext in SUPPORTED_EXTENSIONS:
                                dir_files.append(full)
                    paths.extend(natural_sort(dir_files))
            if paths:
                self.files_dropped.emit(paths)
            event.acceptProposedAction()
            return
        super().dropEvent(event)
        self.order_changed.emit()


# ──────────────────────────────────────────────
# 主窗口
# ──────────────────────────────────────────────
# 数据模型:
#   QListWidgetItem.Qt.ItemDataRole.UserRole → filepath(绝对路径)
#   self.media_info_map[filepath] → MediaInfo
#   列表控件的顺序即合并顺序,不维护额外索引列表
# ──────────────────────────────────────────────
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.media_info_map = {}    # {filepath: MediaInfo}
        self.scan_worker = None
        self.merge_worker = None
        self._scan_generation = 0   # 计数器,丢弃过期扫描回调

        self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
        self.setMinimumSize(800, 600)
        self.resize(900, 700)
        self._load_icon()

        self._init_ui()
        self._apply_style()

    def _load_icon(self):
        for name in ("app_icon.ico", "app_icon.png"):
            p = resource_path("assets", name)
            if os.path.exists(p):
                icon = QIcon(p)
                self.setWindowIcon(icon)
                QApplication.instance().setWindowIcon(icon)
                return

    def _init_ui(self):
        central = QWidget()
        self.setCentralWidget(central)
        main_layout = QVBoxLayout(central)
        main_layout.setSpacing(10)
        main_layout.setContentsMargins(12, 12, 12, 12)

        # 标题
        title_label = QLabel(f"\U0001f3ac {APP_NAME}")
        title_label.setFont(QFont("Microsoft YaHei", 16, QFont.Weight.Bold))
        title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        main_layout.addWidget(title_label)

        subtitle = QLabel("将多个短剧视频文件按顺序合并为一个完整视频")
        subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
        subtitle.setStyleSheet("color: #888; font-size: 12px;")
        main_layout.addWidget(subtitle)

        # 工具栏
        btn_layout = QHBoxLayout()

        self.btn_add = QPushButton("  添加文件")
        self.btn_add.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogStart))
        self.btn_add.setMinimumHeight(36)
        self.btn_add.clicked.connect(self._add_files)

        self.btn_add_dir = QPushButton("  添加目录")
        self.btn_add_dir.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon))
        self.btn_add_dir.setMinimumHeight(36)
        self.btn_add_dir.clicked.connect(self._add_directory)

        self.btn_remove = QPushButton("  移除选中")
        self.btn_remove.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon))
        self.btn_remove.setMinimumHeight(36)
        self.btn_remove.clicked.connect(self._remove_selected)

        self.btn_clear = QPushButton("  清空列表")
        self.btn_clear.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogResetButton))
        self.btn_clear.setMinimumHeight(36)
        self.btn_clear.clicked.connect(self._clear_list)

        self.btn_move_up = QPushButton("  上移 \u2191")
        self.btn_move_up.setMinimumHeight(36)
        self.btn_move_up.clicked.connect(self._move_up)

        self.btn_move_down = QPushButton("  下移 \u2193")
        self.btn_move_down.setMinimumHeight(36)
        self.btn_move_down.clicked.connect(self._move_down)

        for btn in [self.btn_add, self.btn_add_dir, self.btn_remove,
                    self.btn_clear, self.btn_move_up, self.btn_move_down]:
            btn_layout.addWidget(btn)
        main_layout.addLayout(btn_layout)

        # 文件列表
        list_group = QGroupBox("文件列表(可拖拽排序,也可从文件管理器拖入)")
        list_layout = QVBoxLayout(list_group)

        self.file_list = DragDropListWidget()
        self.file_list.files_dropped.connect(self._on_files_dropped)
        self.file_list.order_changed.connect(self._update_count_label)
        self.file_list.model().rowsInserted.connect(self._update_count_label)
        self.file_list.model().rowsRemoved.connect(self._update_count_label)
        list_layout.addWidget(self.file_list)

        self.lbl_count = QLabel("共 0 个文件")
        self.lbl_count.setStyleSheet("color: #666; font-size: 11px;")
        list_layout.addWidget(self.lbl_count)
        main_layout.addWidget(list_group, stretch=1)

        # 选项
        opt_layout = QHBoxLayout()
        self.chk_reencode = QCheckBox("重新编码(兼容性更好,但速度较慢)")
        self.chk_reencode.setToolTip(
            "勾选后会使用 libx264 重新编码视频,\n"
            "适用于源文件编码格式不一致的情况。\n"
            "不勾选则使用流复制,速度快但要求所有文件编码格式一致。"
        )
        opt_layout.addWidget(self.chk_reencode)
        opt_layout.addStretch()

        lbl_format = QLabel("输出格式:")
        self.combo_format = QComboBox()
        self.combo_format.addItems([".mp4", ".mkv"])
        self.combo_format.setFixedWidth(80)
        opt_layout.addWidget(lbl_format)
        opt_layout.addWidget(self.combo_format)
        main_layout.addLayout(opt_layout)

        # 合并按钮 + 进度
        merge_layout = QHBoxLayout()

        self.btn_merge = QPushButton("  开始合并")
        self.btn_merge.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
        self.btn_merge.setMinimumHeight(44)
        self.btn_merge.setFont(QFont("Microsoft YaHei", 12, QFont.Weight.Bold))
        self.btn_merge.setStyleSheet("""
            QPushButton {
                background-color: #2196F3; color: white;
                border: none; border-radius: 6px; padding: 8px 24px;
            }
            QPushButton:hover { background-color: #1976D2; }
            QPushButton:pressed { background-color: #1565C0; }
            QPushButton:disabled { background-color: #BDBDBD; color: #757575; }
        """)
        self.btn_merge.clicked.connect(self._start_merge)

        self.btn_cancel = QPushButton("  取消")
        self.btn_cancel.setMinimumHeight(44)
        self.btn_cancel.setEnabled(False)
        self.btn_cancel.clicked.connect(self._cancel_merge)
        self.btn_cancel.setStyleSheet("""
            QPushButton {
                background-color: #f44336; color: white;
                border: none; border-radius: 6px; padding: 8px 24px;
            }
            QPushButton:hover { background-color: #d32f2f; }
            QPushButton:disabled { background-color: #BDBDBD; color: #757575; }
        """)

        merge_layout.addWidget(self.btn_merge)
        merge_layout.addWidget(self.btn_cancel)
        main_layout.addLayout(merge_layout)

        self.progress = QProgressBar()
        self.progress.setRange(0, 100)
        self.progress.setValue(0)
        self.progress.setTextVisible(True)
        self.progress.setFormat("%p%")
        self.progress.setMinimumHeight(22)
        main_layout.addWidget(self.progress)

        # 日志
        log_group = QGroupBox("运行日志")
        log_layout = QVBoxLayout(log_group)
        self.log_text = QTextEdit()
        self.log_text.setReadOnly(True)
        self.log_text.setMaximumHeight(160)
        self.log_text.setFont(QFont("Consolas", 9))
        self.log_text.setStyleSheet("background-color: #1e1e1e; color: #d4d4d4;")
        log_layout.addWidget(self.log_text)

        btn_clear_log = QPushButton("清空日志")
        btn_clear_log.setFixedWidth(80)
        btn_clear_log.clicked.connect(self.log_text.clear)
        log_layout.addWidget(btn_clear_log, alignment=Qt.AlignmentFlag.AlignRight)
        main_layout.addWidget(log_group)

        self.statusBar().showMessage("就绪 - 请添加要合并的视频文件")

    def _apply_style(self):
        self.setStyleSheet("""
            QMainWindow { background-color: #fafafa; }
            QGroupBox {
                font-weight: bold; border: 1px solid #ddd;
                border-radius: 6px; margin-top: 8px; padding-top: 16px;
            }
            QGroupBox::title {
                subcontrol-origin: margin; subcontrol-position: top left;
                padding: 2px 8px; color: #555;
            }
            QListWidget {
                border: 2px dashed #ccc; border-radius: 6px;
                padding: 4px; font-size: 12px;
            }
            QListWidget::item { padding: 6px; border-bottom: 1px solid #eee; }
            QListWidget::item:selected { background-color: #bbdefb; color: #0d47a1; }
            QListWidget::item:hover { background-color: #e3f2fd; }
            QProgressBar {
                border: 1px solid #bbb; border-radius: 4px;
                text-align: center; background-color: #e0e0e0;
            }
            QProgressBar::chunk { background-color: #4CAF50; border-radius: 3px; }
        """)

    # ── 日志 ──
    def _log(self, message):
        timestamp = datetime.now().strftime("%H:%M:%S")
        self.log_text.append(f"[{timestamp}] {message}")

    # ── 列表项辅助 ──
    def _create_item(self, info):
        item = QListWidgetItem(info.summary())
        item.setData(Qt.ItemDataRole.UserRole, info.filepath)
        self._apply_item_style(item, info)
        return item

    def _apply_item_style(self, item, info):
        if info.scanning:
            item.setForeground(QColor("#555555"))
        elif info.valid:
            item.setForeground(QColor("#2e7d32"))
        else:
            item.setForeground(QColor("#c62828"))

    def _refresh_item_for_path(self, filepath):
        """按 filepath 查找列表项并刷新显示"""
        for i in range(self.file_list.count()):
            item = self.file_list.item(i)
            if item and item.data(Qt.ItemDataRole.UserRole) == filepath:
                info = self.media_info_map.get(filepath)
                if info:
                    item.setText(info.summary())
                    self._apply_item_style(item, info)
                return

    def _get_all_filepaths_in_order(self):
        """从列表控件当前顺序获取所有 filepath(唯一真源)"""
        paths = []
        for i in range(self.file_list.count()):
            item = self.file_list.item(i)
            if item:
                fp = item.data(Qt.ItemDataRole.UserRole)
                if fp:
                    paths.append(fp)
        return paths

    def _get_media_info_in_order(self):
        """按列表顺序返回 MediaInfo 列表"""
        return [self.media_info_map[fp]
                for fp in self._get_all_filepaths_in_order()
                if fp in self.media_info_map]

    # ── 文件添加 ──
    def _add_files(self):
        filepaths, _ = QFileDialog.getOpenFileNames(
            self, "选择视频文件", "",
            "视频文件 (*.mp4 *.mkv *.avi *.mov *.ts *.flv *.wmv);;所有文件 (*)"
        )
        if filepaths:
            self._add_paths(filepaths)

    def _add_directory(self):
        dir_path = QFileDialog.getExistingDirectory(self, "选择包含视频的目录")
        if not dir_path:
            return
        files = []
        for name in os.listdir(dir_path):
            full = os.path.join(dir_path, name)
            if os.path.isfile(full):
                if os.path.splitext(name)[1].lower() in SUPPORTED_EXTENSIONS:
                    files.append(full)
        files = natural_sort(files)
        if files:
            self._add_paths(files)
        else:
            QMessageBox.information(self, "提示", "该目录下没有找到支持的视频文件。")

    def _on_files_dropped(self, paths):
        self._add_paths(paths)

    def _add_paths(self, paths):
        existing = set(self._get_all_filepaths_in_order())
        new_paths = []
        for p in paths:
            normalized = os.path.abspath(p)
            if normalized not in existing and os.path.isfile(normalized):
                existing.add(normalized)
                new_paths.append(normalized)

        if not new_paths:
            return

        for p in new_paths:
            info = MediaInfo(p)
            info.scanning = True
            self.media_info_map[p] = info
            self.file_list.addItem(self._create_item(info))

        self._log(f"添加 {len(new_paths)} 个文件,开始扫描")
        self.statusBar().showMessage(f"正在扫描 {len(new_paths)} 个文件...")
        self._update_count_label()
        self._start_scan(new_paths)

    def _start_scan(self, paths):
        if self.scan_worker and self.scan_worker.isRunning():
            self.scan_worker.cancel()
            self.scan_worker.wait(1000)

        self._scan_generation += 1
        gen = self._scan_generation

        self.scan_worker = ScanWorker(paths)
        self.scan_worker.file_scanned.connect(
            lambda fp, info: self._on_file_scanned(fp, info, gen)
        )
        self.scan_worker.scan_finished.connect(
            lambda n: self._on_scan_finished(n, gen)
        )
        self.scan_worker.start()

    def _on_file_scanned(self, filepath, info, generation):
        if generation != self._scan_generation:
            return   # 旧批次,丢弃
        if filepath not in self.media_info_map:
            return
        info.scanning = False
        self.media_info_map[filepath] = info
        self._refresh_item_for_path(filepath)
        self._update_count_label()

    def _on_scan_finished(self, count, generation):
        if generation != self._scan_generation:
            return
        self._log(f"扫描完成,共处理 {count} 个文件")
        self.statusBar().showMessage("扫描完成")
        self._update_count_label()

    # ── 列表操作 ──
    def _remove_selected(self):
        selected = self.file_list.selectedItems()
        if not selected:
            return
        rows = sorted((self.file_list.row(it) for it in selected), reverse=True)
        for row in rows:
            item = self.file_list.takeItem(row)
            if item:
                fp = item.data(Qt.ItemDataRole.UserRole)
                self.media_info_map.pop(fp, None)
        self._update_count_label()

    def _clear_list(self):
        if self.scan_worker and self.scan_worker.isRunning():
            self.scan_worker.cancel()
            self._scan_generation += 1
        self.file_list.clear()
        self.media_info_map.clear()
        self._update_count_label()
        self._log("列表已清空")

    def _move_up(self):
        row = self.file_list.currentRow()
        if row <= 0:
            return
        item = self.file_list.takeItem(row)
        self.file_list.insertItem(row - 1, item)
        self.file_list.setCurrentItem(item)
        self._update_count_label()

    def _move_down(self):
        row = self.file_list.currentRow()
        if row < 0 or row >= self.file_list.count() - 1:
            return
        item = self.file_list.takeItem(row)
        self.file_list.insertItem(row + 1, item)
        self.file_list.setCurrentItem(item)
        self._update_count_label()

    def _update_count_label(self, *_args):
        infos = self._get_media_info_in_order()
        total_dur = sum(info.duration for info in infos if info.valid)
        count = len(infos)
        m, s = divmod(int(total_dur), 60)
        h, m = divmod(m, 60)
        if h > 0:
            dur_text = f"总时长 {h:d}:{m:02d}:{s:02d}"
        else:
            dur_text = f"总时长 {m:d}:{s:02d}"
        self.lbl_count.setText(f"共 {count} 个文件  |  {dur_text}")

    # ── 合并操作 ──
    def _start_merge(self):
        infos = self._get_media_info_in_order()
        if len(infos) < 2:
            QMessageBox.warning(self, "提示", "请至少添加 2 个文件才能合并。")
            return

        invalid = [info.filename for info in infos if not info.valid]
        if invalid:
            reply = QMessageBox.question(
                self, "提示",
                f"以下 {len(invalid)} 个文件扫描失败,将被跳过:\n\n"
                f"{chr(10).join(invalid[:10])}"
                f"{'...' if len(invalid) > 10 else ''}\n\n"
                f"是否继续合并剩余文件?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            if reply == QMessageBox.StandardButton.No:
                return

        file_paths = [info.filepath for info in infos if info.valid]
        if len(file_paths) < 2:
            QMessageBox.warning(self, "错误", "有效文件不足 2 个,无法合并。")
            return

        ext = self.combo_format.currentText()
        default_name = f"merged_output{ext}"
        first_dir = os.path.dirname(file_paths[0])

        output_path, _ = QFileDialog.getSaveFileName(
            self, "保存合并后的文件",
            os.path.join(first_dir, default_name),
            f"视频文件 (*{ext})"
        )
        if not output_path:
            return

        # 确保扩展名正确
        if not output_path.lower().endswith(ext):
            output_path += ext

        # 校验输出路径不与输入冲突
        abs_output = os.path.abspath(output_path)
        abs_inputs = {os.path.abspath(fp) for fp in file_paths}
        if abs_output in abs_inputs:
            QMessageBox.critical(
                self, "错误",
                "输出文件路径与某个输入文件相同,会导致源文件被覆盖!\n"
                "请选择其他输出路径。"
            )
            return

        # 确认
        total_size = sum(info.size_bytes for info in infos if info.valid)
        size_mb = total_size / (1024 * 1024)
        mode_str = "重新编码" if self.chk_reencode.isChecked() else "流复制(快速)"
        reply = QMessageBox.question(
            self, "确认合并",
            f"即将合并 {len(file_paths)} 个文件\n"
            f"总大小约 {size_mb:.1f} MB\n"
            f"输出格式:{ext}\n"
            f"模式:{mode_str}\n"
            f"输出路径:{output_path}\n\n"
            f"是否开始合并?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        )
        if reply != QMessageBox.StandardButton.Yes:
            return

        # 开始合并
        self._log("=" * 50)
        self._log(f"开始合并 {len(file_paths)} 个文件 | 模式:{mode_str}")
        self.btn_merge.setEnabled(False)
        self.btn_cancel.setEnabled(True)
        self.progress.setValue(0)
        self.statusBar().showMessage("正在合并...")

        self.merge_worker = MergeWorker(
            file_paths=file_paths,
            output_path=output_path,
            reencode=self.chk_reencode.isChecked(),
            output_ext=ext
        )
        self.merge_worker.progress_updated.connect(self.progress.setValue)
        self.merge_worker.log_message.connect(self._log)
        self.merge_worker.merge_finished.connect(self._on_merge_finished)
        self.merge_worker.start()

    def _cancel_merge(self):
        if self.merge_worker and self.merge_worker.isRunning():
            self.merge_worker.cancel()
            self._log("用户取消合并")
            self.statusBar().showMessage("正在取消...")

    def _on_merge_finished(self, success, message):
        self.btn_merge.setEnabled(True)
        self.btn_cancel.setEnabled(False)
        if success:
            self.progress.setValue(100)
            self.statusBar().showMessage("合并完成")
            self._log("\u2705 " + message.replace("\n", " | "))
            QMessageBox.information(self, "合并完成", message)
        else:
            self.progress.setValue(0)
            self.statusBar().showMessage("合并失败")
            self._log("\u274c " + message.replace("\n", " | "))
            QMessageBox.critical(self, "合并失败", message)

    def closeEvent(self, event):
        if self.scan_worker and self.scan_worker.isRunning():
            self.scan_worker.cancel()
            self._scan_generation += 1
        if self.merge_worker and self.merge_worker.isRunning():
            reply = QMessageBox.question(
                self, "确认退出",
                "合并正在进行中,确定要退出吗?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
            )
            if reply == QMessageBox.StandardButton.No:
                event.ignore()
                return
            self.merge_worker.cancel()
            self.merge_worker.wait(3000)
        event.accept()


# ──────────────────────────────────────────────
# 程序入口
# ──────────────────────────────────────────────
def main():
    sys.excepthook = exception_hook

    # PySide6 (Qt6) 默认开启高 DPI 支持,无需手动设置

    app = QApplication(sys.argv)
    app.setApplicationName(APP_NAME)
    app.setApplicationVersion(APP_VERSION)

    # 应用图标
    for name in ("app_icon.ico", "app_icon.png"):
        p = resource_path("assets", name)
        if os.path.exists(p):
            app.setWindowIcon(QIcon(p))
            break

    # 检测 ffmpeg
    if not check_ffmpeg() or not check_ffprobe():
        show_ffmpeg_missing_dialog()
        sys.exit(1)

    window = MainWindow()
    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
相关推荐
李可以量化2 小时前
量化之MiniQMT 实战:一键读取通达信自选股并实时监控涨跌幅(附完整可运行代码)
开发语言·python·量化·qmt·ptrade
CTA量化套保2 小时前
一个账户跑多个期货策略:仓位与报单隔离思路
python·区块链
机汇五金_2 小时前
影响交换机箱体使用寿命的几个关键因素
运维·服务器·网络·python
子午2 小时前
基于DeepSeek的酒店客房管理系统~Python+DeepSeek智能问答+Vue3+Web网站系统
开发语言·前端·python
编程大师哥2 小时前
最高效的 IO 并发方案
linux·网络·python
Hello:CodeWorld2 小时前
Dify 从入门到实战:部署、模型对接与企业级 AI 应用开发全教程
人工智能·python·架构·ai编程
本地化文档2 小时前
black-docs-l10n
python·github·gitcode·sphinx
Dream_ksw2 小时前
Python 基础
开发语言·python
清水白石0083 小时前
从打印对象到高质量调试:彻底理解 Python 中 `__repr__` 和 `__str__` 的区别
开发语言·python