"""FFmpeg / FFprobe interactions and command building.""" import subprocess import json from .constants import FORMAT_INFO from .utils import format_time def get_audio_duration(input_file: str) -> float: """ Retrieve the total duration (in seconds) of the input file using ffprobe. Args: input_file: Path to the media file. Returns: Duration in seconds as a float. """ cmd = [ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', input_file ] result = subprocess.run(cmd, capture_output=True, text=True, check=False) return float(result.stdout.strip()) def has_stream_type(input_file: str, stream_type: str) -> bool: """ Check whether the input file contains a stream of the given type. Args: input_file: Path to the media file. stream_type: 'a' for audio, 'v' for video, 's' for subtitle. Returns: True if at least one such stream exists, False otherwise. """ cmd = [ 'ffprobe', '-v', 'error', '-select_streams', stream_type, '-show_entries', 'stream=codec_type', '-of', 'default=noprint_wrappers=1:nokey=1', input_file ] result = subprocess.run(cmd, capture_output=True, text=True, check=False) return bool(result.stdout.strip()) def get_audio_codec(input_file: str) -> str: """ Return the codec name of the first audio stream. Args: input_file: Path to the media file. Returns: Codec name as a lowercase string, or None if no audio stream exists. """ cmd = [ 'ffprobe', '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=codec_name', '-of', 'default=noprint_wrappers=1:nokey=1', input_file ] result = subprocess.run(cmd, capture_output=True, text=True, check=False) codec = result.stdout.strip().lower() return codec if codec else None def get_stream_info(input_file: str): """ Collect information about the streams present in the input file. Args: input_file: Path to the media file. Returns: A dictionary with keys: has_audio, has_video, has_subtitle, audio_codec. """ return { 'has_audio': has_stream_type(input_file, 'a'), 'has_video': has_stream_type(input_file, 'v'), 'has_subtitle': has_stream_type(input_file, 's'), 'audio_codec': get_audio_codec(input_file) } def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_path, stream_info, format_opt, transcode_audio, drop_video, drop_subs, metadata=None): """ Construct the FFmpeg command line as a list of arguments. Args: input_file: Path to the input media file. start_seconds: Start time for the segment (in seconds). duration_seconds: Duration of the segment (in seconds). output_path: Destination path for the output file. stream_info: Dictionary from get_stream_info(). format_opt: Output container format (e.g., 'mp3'). transcode_audio: Audio codec to transcode to (or None). drop_video: True to remove video streams. drop_subs: True to remove subtitle streams. metadata: Optional dict of metadata key/value pairs to write. Returns: A list of command‑line arguments suitable for subprocess.run(). """ cmd = [ 'ffmpeg', '-i', input_file, '-ss', format_time(start_seconds), '-t', format_time(duration_seconds) ] # -------------------- Clear all original metadata -------------------- cmd.append('-map_metadata') cmd.append('-1') # -------------------- Apply custom metadata -------------------- if metadata: for key, value in metadata.items(): if value is not None and value != '': cmd.extend(['-metadata', f"{key}={value}"]) # -------------------- Stream mapping -------------------- # Map the streams we want to keep. if drop_video and drop_subs: cmd.extend(['-map', '0:a:0']) elif drop_video: cmd.extend(['-map', '0:a:0', '-map', '0:s?']) elif drop_subs: cmd.extend(['-map', '0:a:0', '-map', '0:v:0']) else: cmd.extend(['-map', '0']) # -------------------- Audio codec -------------------- if transcode_audio: cmd.extend(['-c:a', transcode_audio]) if transcode_audio in ('libmp3lame', 'mp3'): cmd.extend(['-b:a', '192k']) elif transcode_audio in ('libopus', 'opus'): cmd.extend(['-b:a', '128k']) else: cmd.extend(['-c:a', 'copy']) # -------------------- Video codec -------------------- if not drop_video and stream_info['has_video']: cmd.extend(['-c:v', 'copy']) else: cmd.append('-vn') # -------------------- Subtitle codec -------------------- if not drop_subs and stream_info['has_subtitle']: cmd.extend(['-c:s', 'copy']) else: cmd.append('-sn') # -------------------- Output format -------------------- if format_opt: ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) cmd.extend(['-f', ffmpeg_format]) # Overwrite output if it already exists. cmd.extend(['-y', output_path]) return cmd def get_metadata(input_file: str) -> dict: """ Retrieve metadata from the input file using ffprobe with JSON output. Returns a dict with: - album: merged from all sources (last wins) - title: merged from all sources (last wins) - comments: list of (stream_index, comment) tuples """ cmd = [ 'ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags:stream_tags', input_file ] result = subprocess.run(cmd, capture_output=True, text=True, check=False) if result.returncode != 0: return {'album': None, 'title': None, 'comments': []} try: data = json.loads(result.stdout) # Collect album and title (merged, last wins). album = None title = None comments = [] # list of (stream_index, comment) # Format tags. fmt_tags = data.get('format', {}).get('tags', {}) album = fmt_tags.get('album') or album title = fmt_tags.get('title') or title # Format does not have a stream index; we'll treat it as -1 if needed. # Stream tags. for idx, stream in enumerate(data.get('streams', [])): stream_tags = stream.get('tags', {}) # Album and title: update if present. if 'album' in stream_tags: album = stream_tags['album'] if 'title' in stream_tags: title = stream_tags['title'] # Comment: collect all occurrences. if 'comment' in stream_tags: comments.append((idx, stream_tags['comment'])) return { 'album': album, 'title': title, 'comments': comments } except (json.JSONDecodeError, KeyError): return {'album': None, 'title': None, 'comments': []}