Last active
April 19, 2025 09:22
-
-
Save xavetar/cd84a14172e454eab4fe899086607ad1 to your computer and use it in GitHub Desktop.
Cue splitter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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