588 lines
24 KiB
Python
588 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
MKV Tool GUI — Extract & Pack (v2, faster Extract)
|
||
|
||
Changes vs previous version
|
||
- Non-blocking ffprobe: UI no longer freezes while loading MKV. Shows a small loading indicator.
|
||
- Slim ffprobe query: only the fields we need → faster on big files / network drives.
|
||
- Nicer "table" list for Extract: columns = [✓] | Type | Codec | Lang | Output filename (editable).
|
||
- No "Keine Kapitel" line; if chapters exist, they appear as their own selectable row.
|
||
- "Extract…" dialog starts in the same folder as the opened MKV.
|
||
- Attachments: now extractable (via ffmpeg -dump_attachment).
|
||
- Predicted filenames shown/used; you can edit them before extracting.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import shutil
|
||
import threading
|
||
import subprocess
|
||
import tkinter as tk
|
||
from tkinter import ttk, filedialog, messagebox
|
||
from pathlib import Path
|
||
from tkinterdnd2 import DND_FILES, TkinterDnD
|
||
|
||
APP_TITLE = "MKV Tool GUI — Extract & Pack"
|
||
TEXT_SUB_CODECS = {"subrip", "srt", "ass", "ssa", "webvtt", "mov_text"}
|
||
IMAGE_SUB_CODECS = {"hdmv_pgs_subtitle", "dvd_subtitle", "xsub", "dvb_subtitle"}
|
||
|
||
# ----------------------- helpers -----------------------
|
||
|
||
def which(cmd: str):
|
||
return shutil.which(cmd)
|
||
|
||
def ensure_tools_or_die():
|
||
missing = [name for name in ("ffmpeg", "ffprobe", "mkvextract") if which(name) is None]
|
||
if missing:
|
||
messagebox.showerror("Missing tools", "Install MKVToolNix and FFmpeg and add to PATH: " + ", ".join(missing))
|
||
sys.exit(1)
|
||
|
||
def run(cmd, cwd=None):
|
||
proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||
out, err = proc.communicate()
|
||
return proc.returncode, out, err
|
||
|
||
def run_streaming(cmd, on_stderr_line, cwd=None):
|
||
proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
|
||
for line in iter(proc.stderr.readline, ""):
|
||
on_stderr_line(line.rstrip())
|
||
proc.stderr.close()
|
||
proc.wait()
|
||
return proc.returncode
|
||
|
||
def safe_name(s: str) -> str:
|
||
s = s.replace("/", "_")
|
||
return "".join(ch for ch in s if ch.isalnum() or ch in " ._-=()+[]{}")
|
||
|
||
def ffprobe_minimal_json(input_path: str) -> dict:
|
||
"""
|
||
Only fetch essentials: stream index/type/codec + language/title/filename tags, disposition (default/forced),
|
||
and chapters. Avoid show_format and other heavy fields.
|
||
"""
|
||
cmd = [
|
||
"ffprobe", "-v", "error",
|
||
"-show_entries",
|
||
"stream=index,codec_type,codec_name,disposition:stream_tags=language,title,filename",
|
||
"-show_chapters",
|
||
"-of", "json",
|
||
input_path
|
||
]
|
||
rc, out, err = run(cmd)
|
||
if rc != 0:
|
||
raise RuntimeError(err.strip() or "ffprobe failed")
|
||
return json.loads(out)
|
||
|
||
def human_type(ctype: str) -> str:
|
||
return {"video":"Video", "audio":"Audio", "subtitle":"Subtitle", "attachment":"Attachment"}.get(ctype, ctype)
|
||
|
||
def default_ext_for_stream(ctype: str, codec: str, lang: str, base: str, ord_num: int, attname: str|None, force_text_sub_to_srt=True):
|
||
codec = (codec or "").lower()
|
||
if ctype == "video":
|
||
# Safe default: single-track MKV (container only). Avoids raw ES edge-cases.
|
||
return f"{base}.video{ord_num:02d}.{lang}.mkv"
|
||
if ctype == "audio":
|
||
return f"{base}.audio{ord_num:02d}.{lang}.mka"
|
||
if ctype == "subtitle":
|
||
if force_text_sub_to_srt and codec in TEXT_SUB_CODECS:
|
||
return f"{base}.sub{ord_num:02d}.{lang}.srt"
|
||
# image or unknown: keep as Matroska subtitle track
|
||
return f"{base}.sub{ord_num:02d}.{lang}.mks"
|
||
if ctype == "attachment":
|
||
# Use original filename if available; prefix with order to avoid clashes.
|
||
if attname:
|
||
return f"{base}.att{ord_num:02d}.{safe_name(attname)}"
|
||
return f"{base}.att{ord_num:02d}.bin"
|
||
return f"{base}.stream{ord_num:02d}.{lang}.bin"
|
||
|
||
# ----------------------- UI widgets -----------------------
|
||
|
||
class Scrollable(ttk.Frame):
|
||
def __init__(self, master):
|
||
super().__init__(master)
|
||
self.canvas = tk.Canvas(self, highlightthickness=0)
|
||
self.vbar = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
|
||
self.inner = ttk.Frame(self.canvas)
|
||
self.inner.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
|
||
self.window = self.canvas.create_window((0,0), window=self.inner, anchor="nw")
|
||
self.canvas.configure(yscrollcommand=self.vbar.set)
|
||
self.canvas.pack(side="left", fill="both", expand=True)
|
||
self.vbar.pack(side="right", fill="y")
|
||
# resize inner width with canvas
|
||
self.canvas.bind("<Configure>", lambda e: self.canvas.itemconfigure(self.window, width=e.width))
|
||
|
||
def _on_mousewheel(self, event):
|
||
# Platform-aware scrolling
|
||
if sys.platform == "darwin":
|
||
delta = event.delta
|
||
else:
|
||
delta = event.delta / 120
|
||
self.canvas.yview_scroll(int(-1 * delta), "units")
|
||
|
||
def bind_scroll_to_widgets(self, widgets):
|
||
for widget in widgets:
|
||
widget.bind("<MouseWheel>", self._on_mousewheel)
|
||
# For Linux
|
||
widget.bind("<Button-4>", self._on_mousewheel)
|
||
widget.bind("<Button-5>", self._on_mousewheel)
|
||
|
||
class ExtractTab(ttk.Frame):
|
||
def __init__(self, master):
|
||
super().__init__(master)
|
||
self.input_path = None
|
||
self.items = [] # list of dicts: for streams and optional chapters row
|
||
self.rows = [] # UI row state
|
||
self.loading = None
|
||
|
||
top = ttk.Frame(self); top.pack(fill="x", padx=10, pady=8)
|
||
self.open_btn = ttk.Button(top, text="Open MKV…", command=self.open_file)
|
||
self.open_btn.pack(side="left")
|
||
self.path_var = tk.StringVar(value="No file loaded")
|
||
ttk.Label(top, textvariable=self.path_var).pack(side="left", padx=10)
|
||
|
||
self.scroll = Scrollable(self); self.scroll.pack(fill="both", expand=True, padx=10, pady=(0,8))
|
||
|
||
# Bottom
|
||
bottom = ttk.Frame(self); bottom.pack(fill="x", padx=10, pady=8)
|
||
self.extract_btn = ttk.Button(bottom, text="Extract…", command=self.extract, state="disabled")
|
||
self.extract_btn.pack(side="right")
|
||
|
||
# ---------- loading / ffprobe ----------
|
||
|
||
def show_loading(self, msg="Loading…"):
|
||
if self.loading: return
|
||
self.loading = ttk.Frame(self.scroll.inner)
|
||
self.loading.pack(fill="x", pady=12)
|
||
ttk.Label(self.loading, text=msg).pack(side="left")
|
||
pb = ttk.Progressbar(self.loading, mode="indeterminate", length=200)
|
||
pb.pack(side="left", padx=8)
|
||
pb.start(10)
|
||
|
||
def hide_loading(self):
|
||
if self.loading and self.loading.winfo_exists():
|
||
for w in self.loading.winfo_children(): w.destroy()
|
||
self.loading.destroy()
|
||
self.loading = None
|
||
|
||
def clear_rows(self):
|
||
for child in self.scroll.inner.winfo_children():
|
||
child.destroy()
|
||
self.rows.clear()
|
||
|
||
def open_file(self):
|
||
p = filedialog.askopenfilename(title="Select MKV", filetypes=[("Matroska","*.mkv *.webm"), ("All files","*.*")])
|
||
if p:
|
||
self.load_file(p)
|
||
|
||
def load_file(self, p: str):
|
||
self.input_path = p
|
||
self.path_var.set(p)
|
||
self.extract_btn.config(state="disabled")
|
||
self.clear_rows()
|
||
self.show_loading()
|
||
|
||
def worker():
|
||
try:
|
||
data = ffprobe_minimal_json(p)
|
||
# Build typed list with ordinals per type
|
||
streams = [s for s in data.get("streams", []) if s.get("codec_type") in {"video","audio","subtitle","attachment"}]
|
||
ord_counters = {"video":0,"audio":0,"subtitle":0,"attachment":0}
|
||
items = []
|
||
for s in streams:
|
||
ctype = s.get("codec_type")
|
||
ordn = ord_counters[ctype]; ord_counters[ctype]+=1
|
||
tags = s.get("tags") or {}
|
||
items.append({
|
||
"kind":"stream",
|
||
"ctype": ctype,
|
||
"codec": s.get("codec_name",""),
|
||
"lang": (tags.get("language") or tags.get("LANGUAGE") or "und"),
|
||
"title": tags.get("title",""),
|
||
"attname": tags.get("filename","") if ctype=="attachment" else "",
|
||
"index": s.get("index"),
|
||
"type_ord": ordn,
|
||
"selected": True,
|
||
})
|
||
# chapters?
|
||
chapters_present = bool(data.get("chapters"))
|
||
if chapters_present:
|
||
items.append({
|
||
"kind":"chapters",
|
||
"ctype":"chapters",
|
||
"codec":"ffmetadata",
|
||
"lang":"",
|
||
"title":"",
|
||
"index": None,
|
||
"type_ord": 0,
|
||
"selected": True,
|
||
})
|
||
# compute suggested names
|
||
base = Path(p).stem
|
||
for it in items:
|
||
if it["kind"]=="stream":
|
||
it["outname"] = default_ext_for_stream(it["ctype"], it["codec"], it["lang"], base, it["type_ord"], it.get("attname"))
|
||
else:
|
||
it["outname"] = f"{base}.chapters.txt"
|
||
self.items = items
|
||
self.after(0, self.build_rows_ui)
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("ffprobe error", str(e)))
|
||
finally:
|
||
self.after(0, self.hide_loading)
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
# ---------- UI table ----------
|
||
|
||
def build_rows_ui(self):
|
||
self.clear_rows()
|
||
|
||
# Create a single grid container for all rows
|
||
grid = ttk.Frame(self.scroll.inner)
|
||
grid.pack(fill="x", padx=10)
|
||
|
||
# Configure column weights and widths
|
||
widths = (40, 90, 120, 80, 520)
|
||
for i, w in enumerate(widths):
|
||
grid.grid_columnconfigure(i, minsize=w)
|
||
grid.grid_columnconfigure(4, weight=1) # Filename should expand
|
||
|
||
# Table header
|
||
cols = ("", "Type", "Codec", "Lang", "Output filename")
|
||
for i, txt in enumerate(cols):
|
||
lbl = ttk.Label(grid, text=txt, font=("", 10, "bold"))
|
||
lbl.grid(row=0, column=i, sticky="w", padx=(2 if i else 0), pady=(0,4))
|
||
|
||
for i, it in enumerate(self.items):
|
||
row_idx = i + 1
|
||
# checkbox
|
||
var = tk.BooleanVar(value=it["selected"])
|
||
ck = ttk.Checkbutton(grid, variable=var)
|
||
ck.grid(row=row_idx, column=0, sticky="w")
|
||
|
||
# type / codec / lang
|
||
ttk.Label(grid, text=("Chapters" if it["kind"]=="chapters" else human_type(it["ctype"]))).grid(row=row_idx, column=1, sticky="w", padx=6)
|
||
ttk.Label(grid, text=(it["codec"] or "-")).grid(row=row_idx, column=2, sticky="w", padx=6)
|
||
ttk.Label(grid, text=(it["lang"] if it["kind"]!="chapters" else "")).grid(row=row_idx, column=3, sticky="w", padx=6)
|
||
|
||
# filename (editable)
|
||
name_var = tk.StringVar(value=it["outname"])
|
||
ent = ttk.Entry(grid, textvariable=name_var, width=60)
|
||
ent.grid(row=row_idx, column=4, sticky="we", padx=6)
|
||
|
||
self.rows.append({
|
||
"var": var,
|
||
"name_var": name_var,
|
||
"item": it,
|
||
})
|
||
|
||
self.extract_btn.config(state=("normal" if self.rows else "disabled"))
|
||
|
||
# Bind scrolling to all widgets in the grid
|
||
widgets_to_bind = [grid]
|
||
for child in grid.winfo_children():
|
||
widgets_to_bind.append(child)
|
||
self.scroll.bind_scroll_to_widgets(widgets_to_bind)
|
||
|
||
# ---------- extract ----------
|
||
|
||
def extract(self):
|
||
if not (self.input_path and self.rows):
|
||
return
|
||
# default directory: folder of the MKV
|
||
initial = os.path.dirname(self.input_path)
|
||
outdir = filedialog.askdirectory(title="Choose output folder", initialdir=initial)
|
||
if not outdir:
|
||
return
|
||
|
||
# Build task list
|
||
tasks = []
|
||
infile = self.input_path
|
||
for row in self.rows:
|
||
if not row["var"].get():
|
||
continue
|
||
it = row["item"]
|
||
outname = row["name_var"].get().strip()
|
||
outname = safe_name(outname)
|
||
outfile = os.path.join(outdir, outname)
|
||
if it["kind"] == "chapters":
|
||
cmd = ["ffmpeg","-y","-i", infile, "-map_chapters","0", "-f","ffmetadata", outfile]
|
||
tasks.append(("Chapters", outfile, cmd))
|
||
else:
|
||
ctype = it["ctype"]; idx = it["index"]; ordn = it["type_ord"]
|
||
if ctype == "video":
|
||
cmd = ["ffmpeg","-y","-i", infile, "-map", f"0:v:{ordn}", "-c", "copy", outfile]
|
||
elif ctype == "audio":
|
||
cmd = ["ffmpeg","-y","-i", infile, "-map", f"0:a:{ordn}", "-c", "copy", outfile]
|
||
elif ctype == "subtitle":
|
||
codec = (it["codec"] or "").lower()
|
||
if codec in TEXT_SUB_CODECS and outfile.lower().endswith(".srt"):
|
||
cmd = ["ffmpeg","-y","-i", infile, "-map", f"0:s:{ordn}", "-c:s", "srt", outfile]
|
||
else:
|
||
cmd = ["ffmpeg","-y","-i", infile, "-map", f"0:s:{ordn}", "-c", "copy", outfile]
|
||
elif ctype == "attachment":
|
||
att_id = ordn + 1
|
||
cmd = ["mkvextract", infile, "attachments", f"{att_id}:{outfile}"]
|
||
else:
|
||
continue
|
||
tasks.append((human_type(ctype), outfile, cmd))
|
||
|
||
if not tasks:
|
||
messagebox.showinfo("Nothing selected", "Bitte mindestens ein Element auswählen.")
|
||
return
|
||
|
||
LogDialog(self, "Extracting…", tasks=tasks).start()
|
||
|
||
class PackTab(ttk.Frame):
|
||
"""Unchanged from previous version (packing works as before)."""
|
||
def __init__(self, master):
|
||
super().__init__(master)
|
||
|
||
self.video_file = None
|
||
self.audio_files = []
|
||
self.sub_files = []
|
||
self.chap_file = None
|
||
self.font_files = []
|
||
|
||
bar = ttk.Frame(self); bar.pack(fill="x", pady=(8,0), padx=10)
|
||
self.btn_add_video = ttk.Button(bar, text="Add Video", command=self.add_video); self.btn_add_video.pack(side="left", padx=4)
|
||
self.btn_add_audio = ttk.Button(bar, text="Add Audio", command=self.add_audio); self.btn_add_audio.pack(side="left", padx=4)
|
||
self.btn_add_sub = ttk.Button(bar, text="Add Subtitles", command=self.add_sub); self.btn_add_sub.pack(side="left", padx=4)
|
||
self.btn_add_typefaces = ttk.Button(bar, text="Add Typefaces", command=self.add_typefaces); self.btn_add_typefaces.pack(side="left", padx=4)
|
||
self.btn_add_chap = ttk.Button(bar, text="Add Chapters", command=self.add_chapters); self.btn_add_chap.pack(side="left", padx=4)
|
||
|
||
ttk.Label(self, text="Streams are muxed with -c copy (no re-encode).", foreground="#666").pack(fill="x", padx=10, pady=6)
|
||
|
||
self.scroll = Scrollable(self); self.scroll.pack(fill="both", expand=True, padx=10, pady=8)
|
||
|
||
self.pack_btn = ttk.Button(self, text="Pack…", command=self.pack_mkv, state="disabled"); self.pack_btn.pack(side="right", padx=10, pady=8)
|
||
|
||
self.refresh_ui()
|
||
|
||
def refresh_ui(self):
|
||
for c in self.scroll.inner.winfo_children():
|
||
c.destroy()
|
||
|
||
def add_row(kind, path, on_remove):
|
||
row = ttk.Frame(self.scroll.inner); row.pack(fill="x", pady=2)
|
||
ttk.Button(row, text="×", width=2, command=on_remove).pack(side="left")
|
||
ttk.Label(row, text=f"{kind}: {path}").pack(side="left", padx=6)
|
||
|
||
if self.video_file:
|
||
add_row("Video", self.video_file, self.remove_video)
|
||
for p in self.audio_files:
|
||
add_row("Audio", p, lambda p=p: self.remove_audio(p))
|
||
for p in self.sub_files:
|
||
add_row("Subtitle", p, lambda p=p: self.remove_sub(p))
|
||
for p in self.font_files:
|
||
add_row("Typeface", p, lambda p=p: self.remove_typeface(p))
|
||
if self.chap_file:
|
||
add_row("Chapters", self.chap_file, self.remove_chapters)
|
||
|
||
self.btn_add_video.config(state="disabled" if self.video_file else "normal")
|
||
self.btn_add_chap.config(state="disabled" if self.chap_file else "normal")
|
||
self.pack_btn.config(state="normal" if self.video_file else "disabled")
|
||
|
||
def add_video(self):
|
||
p = filedialog.askopenfilename(title="Choose video", filetypes=[("Video","*.mkv *.mp4 *.mov *.m4v *.avi *.webm *.ts *.m2ts *.mxf *.hevc *.h264 *.h265"), ("All files","*.*")])
|
||
if p:
|
||
self.video_file = p; self.refresh_ui()
|
||
|
||
def add_audio(self):
|
||
paths = filedialog.askopenfilenames(title="Choose audio", filetypes=[("Audio","*.mka *.m4a *.aac *.ac3 *.dts *.eac3 *.opus *.flac *.mp3 *.wav *.oga *.ogg"), ("All files","*.*")])
|
||
for p in paths or []:
|
||
if p not in self.audio_files: self.audio_files.append(p)
|
||
self.refresh_ui()
|
||
|
||
def add_sub(self):
|
||
paths = filedialog.askopenfilenames(title="Choose subtitles", filetypes=[("Subtitles","*.srt *.ass *.ssa *.vtt *.webvtt *.mks"), ("All files","*.*")])
|
||
for p in paths or []:
|
||
if p not in self.sub_files: self.sub_files.append(p)
|
||
self.refresh_ui()
|
||
|
||
def add_chapters(self):
|
||
p = filedialog.askopenfilename(title="Choose chapters (ffmetadata)", filetypes=[("FFmetadata","*.txt *.ffmeta"), ("All files","*.*")])
|
||
if p:
|
||
self.chap_file = p; self.refresh_ui()
|
||
|
||
def add_typefaces(self):
|
||
paths = filedialog.askopenfilenames(title="Choose typefaces", filetypes=[("Typefaces","*.ttf *.otf"), ("All files","*.*")])
|
||
for p in paths or []:
|
||
if p not in self.font_files: self.font_files.append(p)
|
||
self.refresh_ui()
|
||
|
||
def add_files(self, paths: list[str]):
|
||
video_exts = {".mkv", ".mp4", ".mov", ".m4v", ".avi", ".webm", ".ts", ".m2ts", ".mxf", ".hevc", ".h264", ".h265"}
|
||
audio_exts = {".mka", ".m4a", ".aac", ".ac3", ".dts", ".eac3", ".opus", ".flac", ".mp3", ".wav", ".oga", ".ogg"}
|
||
sub_exts = {".srt", ".ass", ".ssa", ".vtt", ".webvtt", ".mks"}
|
||
font_exts = {".ttf", ".otf"}
|
||
chap_exts = {".txt", ".ffmeta"}
|
||
for p in paths:
|
||
ext = Path(p).suffix.lower()
|
||
if ext in video_exts and not self.video_file:
|
||
self.video_file = p
|
||
elif ext in audio_exts and p not in self.audio_files:
|
||
self.audio_files.append(p)
|
||
elif ext in sub_exts and p not in self.sub_files:
|
||
self.sub_files.append(p)
|
||
elif ext in font_exts and p not in self.font_files:
|
||
self.font_files.append(p)
|
||
elif ext in chap_exts and not self.chap_file:
|
||
self.chap_file = p
|
||
self.refresh_ui()
|
||
|
||
def remove_video(self):
|
||
self.video_file = None; self.refresh_ui()
|
||
|
||
def remove_audio(self, p):
|
||
self.audio_files = [x for x in self.audio_files if x != p]; self.refresh_ui()
|
||
|
||
def remove_sub(self, p):
|
||
self.sub_files = [x for x in self.sub_files if x != p]; self.refresh_ui()
|
||
|
||
def remove_typeface(self, p):
|
||
self.font_files = [x for x in self.font_files if x != p]; self.refresh_ui()
|
||
|
||
def remove_chapters(self):
|
||
self.chap_file = None; self.refresh_ui()
|
||
|
||
def pack_mkv(self):
|
||
if not self.video_file: return
|
||
out = filedialog.asksaveasfilename(title="Save MKV as…", defaultextension=".mkv", filetypes=[("Matroska","*.mkv")])
|
||
if not out: return
|
||
|
||
inputs, maps, codecs = [], [], []
|
||
|
||
vid_idx = len(inputs)//2
|
||
inputs += ["-i", self.video_file]
|
||
maps += ["-map", f"{vid_idx}:v:0"]
|
||
codecs += ["-c:v","copy"]
|
||
|
||
for a in self.audio_files:
|
||
idx = len(inputs)//2
|
||
inputs += ["-i", a]
|
||
maps += ["-map", f"{idx}:a:0"]
|
||
if self.audio_files: codecs += ["-c:a","copy"]
|
||
|
||
for s in self.sub_files:
|
||
idx = len(inputs)//2
|
||
inputs += ["-i", s]
|
||
maps += ["-map", f"{idx}:s:0"]
|
||
if self.sub_files: codecs += ["-c:s","copy"]
|
||
|
||
if self.chap_file:
|
||
chap_idx = len(inputs)//2
|
||
inputs += ["-i", self.chap_file]
|
||
maps += ["-map_chapters", f"{chap_idx}"]
|
||
|
||
attachments = []
|
||
attachment_index = 0
|
||
for f in self.font_files:
|
||
attachments += ["-attach", f]
|
||
ext = Path(f).suffix.lower()
|
||
mimetype = None
|
||
if ext == ".ttf":
|
||
mimetype = "application/x-truetype-font"
|
||
elif ext == ".otf":
|
||
mimetype = "application/vnd.ms-opentype"
|
||
|
||
if mimetype:
|
||
attachments += [f"-metadata:s:t:{attachment_index}", f"mimetype={mimetype}"]
|
||
attachment_index += 1
|
||
|
||
cmd = ["ffmpeg","-y"] + inputs + maps + codecs + attachments + [out]
|
||
LogDialog(self, "Packing…", tasks=[("Pack", out, cmd)]).start()
|
||
|
||
class LogDialog(tk.Toplevel):
|
||
def __init__(self, master, title: str, tasks: list[tuple[str,str,list[str] | None]]):
|
||
super().__init__(master)
|
||
self.title(title)
|
||
self.geometry("900x420")
|
||
self.tasks = tasks
|
||
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
||
|
||
self.text = tk.Text(self, wrap="word")
|
||
self.text.pack(fill="both", expand=True)
|
||
self.btn_close = ttk.Button(self, text="Close", command=self.on_close, state="disabled")
|
||
self.btn_close.pack(pady=6)
|
||
|
||
def append(self, s: str):
|
||
self.text.insert("end", s + "\n")
|
||
self.text.see("end")
|
||
self.update_idletasks()
|
||
|
||
def start(self):
|
||
threading.Thread(target=self._run, daemon=True).start()
|
||
|
||
def _run(self):
|
||
ok = True
|
||
for kind, outfile, cmd in self.tasks:
|
||
if not cmd:
|
||
self.after(0, self.append, f"[SKIP] {kind}: {outfile}")
|
||
continue
|
||
self.after(0, self.append, f"[RUN] {kind} → {outfile}")
|
||
self.after(0, self.append, " ".join([f'"{c}"' if " " in c else c for c in cmd]))
|
||
|
||
is_mkvextract = cmd[0] == "mkvextract"
|
||
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
|
||
|
||
# Read both stdout and stderr line by line
|
||
for line in iter(proc.stdout.readline, ""):
|
||
if is_mkvextract: # mkvextract progress is on stdout
|
||
self.after(0, self.append, line.rstrip())
|
||
for line in iter(proc.stderr.readline, ""):
|
||
if not is_mkvextract: # ffmpeg progress is on stderr
|
||
self.after(0, self.append, line.rstrip())
|
||
|
||
proc.wait()
|
||
rc = proc.returncode
|
||
|
||
if rc != 0:
|
||
ok = False
|
||
self.after(0, self.append, f"[ERROR] Return code {rc}")
|
||
break
|
||
else:
|
||
self.after(0, self.append, "[OK] Done.")
|
||
self.after(0, self.btn_close.config, {"state":"normal"})
|
||
if ok:
|
||
self.after(0, messagebox.showinfo, "Done", "All tasks completed.")
|
||
|
||
def on_close(self):
|
||
self.destroy()
|
||
|
||
class App(TkinterDnD.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title(APP_TITLE)
|
||
self.geometry("980x620")
|
||
|
||
ensure_tools_or_die()
|
||
|
||
self.notebook = ttk.Notebook(self); self.notebook.pack(fill="both", expand=True)
|
||
self.extract_tab = ExtractTab(self.notebook)
|
||
self.pack_tab = PackTab(self.notebook)
|
||
self.notebook.add(self.extract_tab, text="Extract")
|
||
self.notebook.add(self.pack_tab, text="Pack")
|
||
|
||
self.drop_target_register(DND_FILES)
|
||
self.dnd_bind('<<Drop>>', self.on_drop)
|
||
|
||
def on_drop(self, event):
|
||
files = self.tk.splitlist(event.data)
|
||
active_tab = self.notebook.index(self.notebook.select())
|
||
if active_tab == 0: # Extract
|
||
mkv_file = next((f for f in files if f.lower().endswith((".mkv",".webm"))), None)
|
||
if mkv_file:
|
||
self.extract_tab.load_file(mkv_file)
|
||
elif active_tab == 1: # Pack
|
||
self.pack_tab.add_files(files)
|
||
|
||
def main():
|
||
app = App()
|
||
app.mainloop()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|