Skip to content

Instantly share code, notes, and snippets.

@xavetar
Last active April 19, 2025 09:22
Show Gist options
  • Select an option

  • Save xavetar/cd84a14172e454eab4fe899086607ad1 to your computer and use it in GitHub Desktop.

Select an option

Save xavetar/cd84a14172e454eab4fe899086607ad1 to your computer and use it in GitHub Desktop.
Cue splitter
import os
import argparse
import subprocess
from typing import Dict, List, Optional, Tuple, TypedDict
class TrackData(TypedDict):
track: int
title: Optional[str]
artist: Optional[str]
indexes: List[float]
genre: Optional[str]
date: Optional[str]
album: Optional[str]
def parse_cue(cue_lines: List[str]) -> Tuple[Dict[str, str], List[TrackData], Optional[str]]:
"""Парсит .cue файл, сохраняя все INDEX для каждого трека."""
general: Dict[str, str] = {}
tracks: List[TrackData] = []
current_file: Optional[str] = None
current_track: Optional[TrackData] = None
for line in cue_lines:
parts: List[str] = line.strip().split(' ', 1)
if not parts:
continue
key = parts[0]
value = parts[1] if len(parts) > 1 else ''
leading_spaces = len(line) - len(line.lstrip())
if leading_spaces == 0:
if key == 'REM':
rem_parts = value.split(' ', 1)
if len(rem_parts) > 1:
rem_key, rem_value = rem_parts
if rem_key == 'GENRE':
general['genre'] = rem_value
elif rem_key == 'DATE':
general['date'] = rem_value
elif key == 'PERFORMER':
general['artist'] = value.replace('"', '')
elif key == 'TITLE':
general['album'] = value.replace('"', '')
elif key == 'FILE':
current_file = value.rsplit(' ', 1)[0].replace('"', '')
elif leading_spaces == 2 and key == 'TRACK':
current_track = {
'track': int(value.split()[0], 10),
'title': None,
'artist': None,
'indexes': [],
'genre': general.get('genre'),
'date': general.get('date'),
'album': general.get('album')
}
tracks.append(current_track)
elif leading_spaces == 4 and current_track is not None:
if key == 'TITLE':
current_track['title'] = value.replace('"', '')
elif key == 'PERFORMER':
current_track['artist'] = value.replace('"', '')
elif key == 'INDEX':
t = list(map(int, value.split(' ', 1)[1].replace('"', '').split(':')))
time: float = (60.0 * t[0]) + float(t[1]) + (t[2] / 100.0)
current_track['indexes'].append(time)
for track in tracks:
if not track['indexes']:
raise ValueError(f"Track {track['track']} has no INDEX entries, invalid .cue format")
return general, tracks, current_file
def get_input_format(current_file: Optional[str]) -> str:
"""Определяет формат входного файла по расширению."""
if not current_file:
return 'flac'
ext = os.path.splitext(current_file)[1].lower()
return ext[1:] if ext else 'flac'
def get_codec_params(input_format: str, output_format: str, transcode: bool, bitrate: Optional[str]) -> Tuple[str, List[str]]:
"""Возвращает расширение файла и параметры кодека для FFmpeg."""
bitrate = bitrate if bitrate else '320k'
if input_format == output_format and not transcode:
return output_format, ['-c:a', 'copy']
if output_format == 'mp3':
ext = 'mp3'
codec = ['-c:a', 'mp3', '-b:a', bitrate]
elif output_format == 'aac':
ext = 'm4a'
codec = ['-c:a', 'aac', '-b:a', bitrate]
elif output_format == 'wav':
ext = 'wav'
codec = ['-c:a', 'pcm_s16le']
elif output_format == 'flac':
ext = 'flac'
codec = ['-c:a', 'flac', '-compression_level', '12']
elif output_format == 'mp4':
ext = 'mp4'
codec = ['-c:a', 'aac', '-b:a', bitrate, '-c:v', 'copy']
else:
raise ValueError(f"Unsupported format: {output_format}")
return ext, codec
def supports_cover(format: str) -> bool:
"""Проверяет, поддерживает ли формат обложку."""
return format in ['mp3', 'aac', 'flac', 'mp4'] # WAV не поддерживает обложки
def get_output_filename(outpath: str, track: TrackData, index: int, split_indexes: bool, ext: str, temp: bool = False) -> str:
"""Генерирует имя выходного файла для трека."""
safe_title = track.get('title', f"Track {track['track']}").replace('/', '_').replace('"', '').replace(':', '_')
safe_artist = track.get('artist', 'Unknown').replace('/', '_').replace('"', '').replace(':', '_')
prefix = 'temp_' if temp else ''
if split_indexes:
return os.path.join(outpath, f"{prefix}{track['track']:02d}-{index+1:02d}. {safe_artist} - {safe_title}.{ext}")
return os.path.join(outpath, f"{prefix}{track['track']:02d}. {safe_artist} - {safe_title}.{ext}")
def get_track_metadata(track: TrackData, general: Dict[str, str], total_tracks: int) -> Dict[str, str]:
"""Генерирует метаданные для трека."""
metadata = {
'artist': track.get('artist', general.get('artist', 'Unknown')),
'title': track.get('title', f"Track {track['track']}"),
'album': track.get('album', general.get('album', 'Unknown')),
'track': f"{track['track']}/{total_tracks}"
}
if 'genre' in track and track['genre']:
metadata['genre'] = track['genre']
if 'date' in track and track['date']:
metadata['date'] = track['date']
return metadata
def build_cut_command(current_file: str, out_file: str, start: float, duration: Optional[float],
codec_params: List[str]) -> List[str]:
"""Строит команду FFmpeg для обрезки аудио."""
cmd: List[str] = ['ffmpeg', '-i', current_file]
cmd.extend(['-ss', f"{start:.10f}"])
if duration is not None:
cmd.extend(['-t', f"{duration:.10f}"])
cmd.extend(codec_params)
cmd.append(out_file)
return cmd
def build_cover_command(input_file: str, out_file: str, metadata: Dict[str, str], cover: str) -> List[str]:
"""Строит команду FFmpeg для добавления обложки."""
cmd: List[str] = ['ffmpeg', '-i', input_file, '-i', cover]
cmd.extend([
'-map', '0:a', # Аудиопоток из первого файла
'-map', '1', # Обложка из второго файла
'-c:a', 'copy', # Копируем аудио
'-c:v', 'mjpeg', # MJPEG для обложки
'-q:v', '1', # Качество обложки
'-metadata:s:v', 'title=Album cover',
'-metadata:s:v', 'comment=Cover (front)',
'-disposition:v', 'attached_pic'
])
cmd.extend(['-map_metadata', '0']) # Копируем исходные метаданные
for k, v in metadata.items():
cmd.extend(['-metadata', f"{k}={v}"])
cmd.append(out_file)
return cmd
def process_track(track: TrackData, track_idx: int, tracks: List[TrackData], general: Dict[str, str],
current_file: str, outpath: str, ext: str, codec_params: List[str],
cover: Optional[str], output_format: str, split_indexes: bool) -> None:
"""Обрабатывает один трек с учетом всех параметров в два прохода."""
if split_indexes:
for i, start in enumerate(track['indexes']):
temp_file = get_output_filename(outpath, track, i, split_indexes, ext, temp=True)
out_file = get_output_filename(outpath, track, i, split_indexes, ext)
metadata = get_track_metadata(track, general, len(tracks))
duration = None
if i + 1 < len(track['indexes']):
duration = track['indexes'][i + 1] - start
elif track_idx + 1 < len(tracks):
next_start = tracks[track_idx + 1]['indexes'][0]
duration = next_start - start
# Первый проход: обрезка аудио
cut_cmd = build_cut_command(current_file, temp_file, start, duration, codec_params)
if not cut_cmd:
print(f"Error: No media file specified in .cue for track {track['track']}")
continue
print(f"Cutting track {track['track']}, segment {i+1}: {' '.join(f'\"{arg}\"' if ' ' in arg else arg for arg in cut_cmd)}")
subprocess.call(cut_cmd)
# Второй проход: добавление обложки (только для поддерживаемых форматов)
if cover and supports_cover(output_format):
cover_cmd = build_cover_command(temp_file, out_file, metadata, cover)
print(f"Adding cover to track {track['track']}, segment {i+1}: {' '.join(f'\"{arg}\"' if ' ' in arg else arg for arg in cover_cmd)}")
subprocess.call(cover_cmd)
os.remove(temp_file) # Удаляем временный файл
else:
# Для WAV просто переименовываем, добавляя метаданные, если нужно
cmd = ['ffmpeg', '-i', temp_file, '-c:a', 'copy']
cmd.extend(['-map_metadata', '0'])
for k, v in metadata.items():
cmd.extend(['-metadata', f"{k}={v}"])
cmd.append(out_file)
print(f"Finalizing track {track['track']}, segment {i+1}: {' '.join(f'\"{arg}\"' if ' ' in arg else arg for arg in cmd)}")
subprocess.call(cmd)
os.remove(temp_file)
else:
start = track['indexes'][0]
temp_file = get_output_filename(outpath, track, 0, split_indexes, ext, temp=True)
out_file = get_output_filename(outpath, track, 0, split_indexes, ext)
metadata = get_track_metadata(track, general, len(tracks))
duration = None
if track_idx + 1 < len(tracks):
next_start = tracks[track_idx + 1]['indexes'][0]
duration = next_start - start
# Первый проход: обрезка аудио
cut_cmd = build_cut_command(current_file, temp_file, start, duration, codec_params)
if not cut_cmd:
print(f"Error: No media file specified in .cue for track {track['track']}")
return
print(f"Cutting track {track['track']}: {' '.join(f'\"{arg}\"' if ' ' in arg else arg for arg in cut_cmd)}")
subprocess.call(cut_cmd)
# Второй проход: добавление обложки (только для поддерживаемых форматов)
if cover and supports_cover(output_format):
cover_cmd = build_cover_command(temp_file, out_file, metadata, cover)
print(f"Adding cover to track {track['track']}: {' '.join(f'\"{arg}\"' if ' ' in arg else arg for arg in cover_cmd)}")
subprocess.call(cover_cmd)
os.remove(temp_file) # Удаляем временный файл
else:
# Для WAV просто переименовываем, добавляя метаданные
cmd = ['ffmpeg', '-i', temp_file, '-c:a', 'copy']
cmd.extend(['-map_metadata', '0'])
for k, v in metadata.items():
cmd.extend(['-metadata', f"{k}={v}"])
cmd.append(out_file)
print(f"Finalizing track {track['track']}: {' '.join(f'\"{arg}\"' if ' ' in arg else arg for arg in cmd)}")
subprocess.call(cmd)
os.remove(temp_file)
def main() -> None:
parser = argparse.ArgumentParser(description='Split media file using cue with FFmpeg')
parser.add_argument('cue', type=str, help='Path to .cue file')
parser.add_argument('--cover', type=str, help='Path to cover image', default=None)
parser.add_argument('--out', type=str, help='Output directory', default=None)
parser.add_argument('--format', type=str, default='flac',
help='Output format (mp3, aac, wav, flac, mp4), default: flac',
choices=['mp3', 'aac', 'wav', 'flac', 'mp4'])
parser.add_argument('--bitrate', type=str, default=None,
help='Audio bitrate (e.g., 128k, 256k, 320k) for lossy formats')
parser.add_argument('--no-transcode', action='store_true',
help='Copy audio stream without re-encoding (ignored if format differs)')
parser.add_argument('--split-indexes', action='store_true',
help='Split tracks by all INDEX entries')
args: argparse.Namespace = parser.parse_args()
cue_file: str = args.cue
cover: Optional[str] = args.cover
output_format: str = args.format
bitrate: Optional[str] = args.bitrate
no_transcode: bool = args.no_transcode
split_indexes: bool = args.split_indexes
if not cue_file:
parser.print_help()
return
try:
cue_lines: List[str] = open(cue_file, encoding='utf-8').read().splitlines()
dirpath: str = os.path.dirname(cue_file)
outpath: str = dirpath if args.out is None else args.out
if not os.path.isdir(outpath):
os.makedirs(outpath, mode=0o777, exist_ok=True)
general, tracks, current_file = parse_cue(cue_lines)
if current_file:
current_file = os.path.join(dirpath, current_file)
input_format = get_input_format(current_file)
if input_format != output_format and no_transcode:
print(f"Warning: --no-transcode ignored because input format ({input_format}) differs from output format ({output_format}). Forcing transcoding.")
transcode = True
else:
transcode = not no_transcode
ext, codec_params = get_codec_params(input_format, output_format, transcode, bitrate)
for track_idx, track in enumerate(tracks):
process_track(track, track_idx, tracks, general, current_file, outpath, ext,
codec_params, cover, output_format, split_indexes)
except ValueError as e:
print(f"Error: {e}")
return
except Exception as e:
print(f"Unexpected error: {e}")
return
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment