Initial commit. Basic featues are implemented
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""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': []}
|
||||
|
||||
Reference in New Issue
Block a user