added support for .aaxc files.

This commit is contained in:
2026-02-13 18:31:01 +01:00
parent 47b452fe5f
commit 6256ba597e
9 changed files with 231 additions and 98 deletions

135
main.py
View File

@@ -6,7 +6,7 @@ import subprocess
import wx
import wx.media
AUDIO_FORMATS = ["mp3", "wav", "aac", "flac", "ogg", "m4a"]
AUDIO_FORMATS = ["mp3", "wav", "aac", "flac", "ogg", "m4a", "m4b"]
VIDEO_FORMATS = ["mp4", "mkv", "mov", "webm"]
@@ -41,6 +41,10 @@ class ConverterFrame(wx.Frame):
self.log_ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY)
self.status = wx.StaticText(panel, label="Ready")
self.status.SetForegroundColour(label_color)
self.progress_gauge = wx.Gauge(panel, range=1000, style=wx.GA_HORIZONTAL)
self.progress_gauge.SetValue(0)
self.progress_label = wx.StaticText(panel, label="Progress")
self.progress_label.SetForegroundColour(label_color)
self.media = wx.media.MediaCtrl(panel, style=wx.SIMPLE_BORDER)
self.play_btn = wx.Button(panel, label="&Play/Pause")
@@ -101,6 +105,8 @@ class ConverterFrame(wx.Frame):
main.Add(form, 0, wx.EXPAND | wx.ALL, 10)
main.Add(self.start_btn, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
main.Add(self.build_player(panel), 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
main.Add(self.progress_label, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 4)
main.Add(self.progress_gauge, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
main.Add(self.log_ctrl, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 10)
main.Add(self.status, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
@@ -356,6 +362,7 @@ class ConverterFrame(wx.Frame):
self.log_ctrl.Clear()
self.status.SetLabel("Running...")
self.start_btn.Disable()
self.reset_progress()
thread = threading.Thread(target=self.run_ffmpeg, args=(in_path, out_path), daemon=True)
thread.start()
@@ -370,9 +377,20 @@ class ConverterFrame(wx.Frame):
output_format = self.format_choice.GetStringSelection().lower()
should_split_mp3 = split_mp3 and output_format == "mp3"
duration = self.get_media_duration(in_path)
self.prepare_progress(duration)
cmd = ["ffmpeg", "-y"]
cmd += ["-progress", "pipe:1", "-nostats"]
if activation_bytes:
cmd += ["-activation_bytes", activation_bytes]
aaxc_key, aaxc_iv = self.get_aaxc_key_iv(in_path)
if aaxc_key and aaxc_iv:
cmd += ["-audible_key", aaxc_key, "-audible_iv", aaxc_iv]
elif os.path.splitext(in_path)[1].lower() == ".aaxc":
self.append_log("Missing or invalid .voucher key/iv for .aaxc file.")
self.finish("Failed")
return
cmd += ["-i", in_path]
if mode == "Audio":
@@ -423,9 +441,14 @@ class ConverterFrame(wx.Frame):
universal_newlines=True,
)
for line in proc.stdout:
self.append_log(line.rstrip())
line = line.rstrip()
if self.handle_progress_line(line, duration):
continue
if line:
self.append_log(line)
proc.wait()
if proc.returncode == 0:
self.set_progress_complete()
self.finish("Done")
else:
self.finish("Failed")
@@ -440,6 +463,114 @@ class ConverterFrame(wx.Frame):
wx.CallAfter(self.status.SetLabel, status_text)
wx.CallAfter(self.start_btn.Enable)
def reset_progress(self):
self.progress_gauge.SetRange(1000)
self.progress_gauge.SetValue(0)
def prepare_progress(self, duration_seconds):
if duration_seconds and duration_seconds > 0:
wx.CallAfter(self.progress_gauge.SetRange, 1000)
wx.CallAfter(self.progress_gauge.SetValue, 0)
else:
wx.CallAfter(self.progress_gauge.Pulse)
def set_progress_value(self, ratio):
ratio = max(0.0, min(1.0, ratio))
value = int(ratio * 1000)
wx.CallAfter(self.progress_gauge.SetValue, value)
def set_progress_complete(self):
wx.CallAfter(self.progress_gauge.SetValue, 1000)
def handle_progress_line(self, line, duration_seconds):
if not line:
return False
if line.startswith("out_time_ms="):
if duration_seconds and duration_seconds > 0:
try:
out_ms = int(line.split("=", 1)[1].strip())
ratio = out_ms / (duration_seconds * 1_000_000)
self.set_progress_value(ratio)
except (ValueError, ZeroDivisionError):
pass
else:
wx.CallAfter(self.progress_gauge.Pulse)
return True
if line.startswith("progress="):
if line.split("=", 1)[1].strip() == "end":
self.set_progress_complete()
return True
return False
def get_media_duration(self, in_path):
if not shutil.which("ffprobe"):
return 0
cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nk=1:nw=1",
in_path,
]
try:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if proc.returncode != 0:
return 0
value = (proc.stdout or "").strip()
return float(value) if value else 0
except Exception:
return 0
def get_aaxc_key_iv(self, in_path):
if os.path.splitext(in_path)[1].lower() != ".aaxc":
return None, None
voucher_path = os.path.splitext(in_path)[0] + ".voucher"
if not os.path.isfile(voucher_path):
return None, None
try:
with open(voucher_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except Exception:
return None, None
if not isinstance(data, dict):
return None, None
# Support multiple voucher shapes:
# - content_license.license_response (current Audible export shape)
# - lilicense_response (legacy/typo compatibility)
# - license_response (flat shape fallback)
license_resp = None
content_license = data.get("content_license")
if isinstance(content_license, dict):
candidate = content_license.get("license_response")
if isinstance(candidate, dict):
license_resp = candidate
if license_resp is None:
candidate = data.get("lilicense_response")
if isinstance(candidate, dict):
license_resp = candidate
if license_resp is None:
candidate = data.get("license_response")
if isinstance(candidate, dict):
license_resp = candidate
if not isinstance(license_resp, dict):
return None, None
key = license_resp.get("key")
iv = license_resp.get("iv")
if not key or not iv:
return None, None
return str(key), str(iv)
def build_capture_output_pattern(self, out_path):
base, ext = os.path.splitext(out_path)
return f"{base}_capture_%03d{ext}"