224 lines
7.1 KiB
Python
224 lines
7.1 KiB
Python
"""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': []}
|
||
|