From 658e4a81ab93592b33cf789503b64d58582d9775 Mon Sep 17 00:00:00 2001 From: Francesco Date: Sat, 25 Apr 2026 00:35:45 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Stream=20codec=20is=20now=20hono?= =?UTF-8?q?red=20when=20picking=20track=20file=20extension=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tiddl/cli/commands/download/downloader.py | 4 +- tiddl/core/utils/ffmpeg.py | 51 +++++++++++++++++++---- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/tiddl/cli/commands/download/downloader.py b/tiddl/cli/commands/download/downloader.py index b5d4b30..ed342a4 100644 --- a/tiddl/cli/commands/download/downloader.py +++ b/tiddl/cli/commands/download/downloader.py @@ -141,7 +141,9 @@ class Downloader: ) return None, False - urls, _ = parse_track_stream(stream) + urls, actual_ext = parse_track_stream(stream) + if filename.suffix.lower() != actual_ext: + filename = filename.with_suffix(actual_ext) download_path = self.download_path / filename quality = track_qualities_color[stream.audioQuality] diff --git a/tiddl/core/utils/ffmpeg.py b/tiddl/core/utils/ffmpeg.py index 2155c21..3a5d5db 100644 --- a/tiddl/core/utils/ffmpeg.py +++ b/tiddl/core/utils/ffmpeg.py @@ -2,9 +2,18 @@ import subprocess from pathlib import Path -def run(cmd: list[str]): - """Run process without printing to terminal""" - subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +class FFmpegError(RuntimeError): + pass + + +def run(cmd: list[str]) -> subprocess.CompletedProcess: + """Run a process; raise `FFmpegError` on non-zero exit with stderr.""" + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + raise FFmpegError( + f"{cmd[0]} failed (rc={r.returncode}): {r.stderr.strip()}" + ) + return r def is_ffmpeg_installed() -> bool: @@ -13,10 +22,25 @@ def is_ffmpeg_installed() -> bool: try: run(["ffmpeg", "-version"]) return True - except FileNotFoundError: + except (FileNotFoundError, FFmpegError): return False +def _probe_audio_codec(source: Path) -> str: + """Return first audio stream's codec_name, or "" if ffprobe is unavailable.""" + try: + r = run([ + "ffprobe", "-v", "error", + "-select_streams", "a:0", + "-show_entries", "stream=codec_name", + "-of", "default=noprint_wrappers=1:nokey=1", + str(source), + ]) + return r.stdout.strip() + except (FileNotFoundError, FFmpegError): + return "" + + def convert_to_mp4(source: Path) -> Path: output_path = source.with_suffix(".mp4") @@ -29,13 +53,26 @@ def convert_to_mp4(source: Path) -> Path: def extract_flac(source: Path) -> Path: """ - Extracts flac audio from mp4 container + Extract FLAC audio from an MP4 container. + + Tidal can serve AAC-in-MP4 for tracks without a lossless master, so the + input may not actually contain FLAC. """ + codec = _probe_audio_codec(source) + if codec and codec != "flac": + target = source.with_suffix(".m4a") + if target != source: + source.replace(target) + return target + + target = source.with_suffix(".flac") tmp = source.with_suffix(".tmp.flac") run(["ffmpeg", "-y", "-i", str(source), "-c", "copy", str(tmp)]) - tmp.replace(source.with_suffix(".flac")) + tmp.replace(target) + if source != target and source.exists(): + source.unlink() - return source.with_suffix(".flac") + return target