Files
mkv_tool_gui/mkv_tool_gui.py
2025-08-19 08:11:43 +02:00

588 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()