From bb883409e1e08d2eb2bff5adb64a19147c394c55 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Wed, 5 Aug 2026 17:11:43 +0500 Subject: [PATCH 1/4] run-name added for images building action --- .gitea/workflows/build_image.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/build_image.yaml b/.gitea/workflows/build_image.yaml index 6b935d8..f62dc4a 100644 --- a/.gitea/workflows/build_image.yaml +++ b/.gitea/workflows/build_image.yaml @@ -1,4 +1,5 @@ name: Build and Push Docker Image +run-name: Production images are being built on: pull_request: -- 2.52.0 From 5cf6812338bfd34895d1670f1a14cc2a64c13ced Mon Sep 17 00:00:00 2001 From: max Date: Fri, 7 Aug 2026 06:34:36 +0000 Subject: [PATCH 2/4] Update README.md --- README.md | 270 ------------------------------------------------------ 1 file changed, 270 deletions(-) diff --git a/README.md b/README.md index ba4de61..23b4f88 100644 --- a/README.md +++ b/README.md @@ -61,276 +61,6 @@ audio_splitter input.mp3 tracks.txt --- -## 🚀 Quick Start - -### 1. Prepare a tracklist file - -Create a `tracks.txt` file with one track per line: - -``` -00:00 Intro -01:30 Song One - Artist A -04:20-06:45 Another Song - Artist B -08:10 Finale - Artist C -``` - -- `00:00` – start‑only timestamp (track ends at next track's start or end of file) -- `04:20-06:45` – explicit start and end timestamps - -### 2. Run the splitter - -```bash -audio_splitter my_album.mp3 tracks.txt -``` - -Output: - -``` -Found 4 tracks. -Detected streams: audio=True, video=False, subs=False -Audio codec: mp3 -Output container: mp3 -Extracting track 1: Intro (00:00:00 - 00:01:30) - -> Saved to: my_album_splits/Intro.mp3 -Extracting track 2: Song One (00:01:30 - 00:04:20) - -> Saved to: my_album_splits/Song One - Artist A.mp3 -... -Done! -``` - ---- - -## ⚙️ Command‑Line Options - -### Basic Options - -| Option | Description | -| ---------------------- | ------------------------------------------------------------- | -| `input_file` | Input audio/video file | -| `tracklist_file` | Tracklist file | -| `-o, --output-dir DIR` | Output directory (default: `_splits`) | -| `--format FORMAT` | Output container format (mp3, m4a, mkv, mp4, ogg, opus, etc.) | -| `--transcode-to CODEC` | Re‑encode audio to CODEC (e.g., libmp3lame, aac, libopus) | -| `--drop-video` | Remove video streams | -| `--drop-subs` | Remove subtitle streams | -| `--dry-run` | Preview parsed tracklist without splitting | - -### Filename Options - -| Option | Description | -| ---------------------------- | --------------------------------------------------------------- | -| `--number-tracks` | Prepend track number (`01 - `) to filenames | -| `--output-template TEMPLATE` | Custom filename template (default: `%an-%tn.%ext`) | -| `--replace-bad-chars` | Replace problematic characters in filenames | -| `--replacement-char CHAR` | Replacement character (default: `_`) | -| `--bad-chars CHARS` | Characters to replace (default includes space and single quote) | - -### Metadata Options - -| Option | Description | -| ------------------------- | ----------------------------------------------- | -| `--album ALBUM` | Set album name (overrides parsed `%al`) | -| `--comment COMMENT` | Set comment text | -| `--no-comment` | Ignore comment entirely | -| `--comment-stream INDEX` | Select comment from a specific stream (0‑based) | -| `--merge-comments` | Merge all comments from all streams | -| `--comment-separator SEP` | Separator for merged comments (default: `; `) | - -### Tracklist Format Options - -| Option | Description | -| --------------------------- | -------------------------------------------------- | -| `--tracklist-format FORMAT` | Custom tracklist format (default: `%ts %tn - %an`) | -| `--skip-existing` | Skip extraction if output file already exists | - -### Other Options - -| Option | Description | -| ------------------- | --------------------------------------------------------- | -| `--delete-original` | Delete the original input file after successful splitting | - ---- - -## 📝 Placeholders Reference - -### Tracklist Format Placeholders (`--tracklist-format`) - -| Placeholder | Meaning | -| ----------- | --------------------------------------------------- | -| `%ts` | **Timestamp** – required (`00:00` or `00:00-01:30`) | -| `%tn` | Track name | -| `%an` | Author/artist | -| `%al` | Album | -| `%date` | Date/year | -| `%ext` | File extension | - -**Default:** `%ts %tn - %an` - -### Output Template Placeholders (`--output-template`) - -| Placeholder | Meaning | -| ----------- | -------------------------------------- | -| `%tn` | Track name | -| `%an` | Author/artist | -| `%al` | Album | -| `%date` | Date/year | -| `%ext` | File extension (without leading dot) | -| `%num` | Track number (zero‑padded, e.g., `01`) | - -**Default:** `%an-%tn.%ext` - ---- - -## 💡 Examples - -### Custom tracklist format - -If your tracklist uses `artist - title [time]`: - -```bash -audio_splitter input.flac tracks.txt \ - --tracklist-format "%an - %tn [%ts]" -``` - -### Custom output filenames - -Name files as `01 - Artist - Song.mp3`: - -```bash -audio_splitter input.flac tracks.txt \ - --output-template "%num - %an - %tn.%ext" -``` - -### Override album and comment - -```bash -audio_splitter input.flac tracks.txt \ - --album "Greatest Hits" \ - --comment "Live recording" -``` - -### Merge multiple comments from input file - -```bash -audio_splitter input.flac tracks.txt \ - --merge-comments \ - --comment-separator " | " -``` - -### Delete original file after splitting - -```bash -audio_splitter input.flac tracks.txt --delete-original -``` - -### Dry‑run to preview parsing - -```bash -audio_splitter input.flac tracks.txt --dry-run -``` - -Output: - -``` -Parsed tracklist: ------------------------------------------------------------- - ts | tn | an ------------------------------------------------------------- - 1 | 00:00 | Intro | - 2 | 01:30 | Song One | Artist A - 3 | 04:20-06:45 | Another Song | Artist B -... ------------------------------------------------------------- -Dry‑run complete. No files were created. -``` - ---- - -## 🐳 Docker - -You can run `audio_splitter` in a Docker container without installing Python or FFmpeg on your host. - -### Pull the Image (Optional) - -```bash -docker pull yourusername/audio_splitter:latest -``` - -### Build the Image Locally - -```bash -docker build -t audio_splitter . -``` - -### Usage - -**You must mount your working directory to `/data` inside the container.** -The container will automatically adjust permissions so that you can read input files and write output files. - -Simply provide the arguments as you would to the `audio_splitter` command: - -```bash -docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt [OPTIONS] -``` - -#### Examples - -**Basic split:** - -```bash -docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt -``` - -**With custom options:** - -```bash -docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --album "Greatest Hits" --format mp3 --number-tracks -``` - -**Dry‑run:** - -```bash -docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --dry-run -``` - -**Help:** - -```bash -docker run --rm audio_splitter --help -``` - -### Output - -All output files are written to the mounted directory on your host (under the default `input_splits/` subdirectory, or any custom `--output-dir` you specify). - -### Permission Handling - -The container automatically adjusts ownership of the mounted `/data` directory so that the container user can read and write files there. No `--user` or `:z` flags are required. - ---- - -## 📂 Project Structure - -``` -audio_splitter/ -├── __init__.py # Package initialisation -├── constants.py # Global constants (FORMAT_INFO, DEFAULT_BAD_CHARS) -├── utils.py # Generic helpers (timestamps, string manipulation) -├── tracklist.py # Tracklist parsing with custom formats -├── ffmpeg.py # FFmpeg/FFprobe interactions and command building -├── timestamp.py # Timestamp parsing and resolution -├── filename.py # Output filename generation -├── metadata.py # Metadata selection and building -├── formats.py # Container format decision and validation -├── core.py # Main orchestration logic -├── main.py # Command‑line interface -├── docker-entrypoint.sh # Docker entrypoint script -├── Dockerfile # Docker image definition -└── README.md # This file -``` - ---- - ## 🤝 Contributing Contributions are welcome! Please follow these steps: -- 2.52.0 From c27f832c955662f96158c60e8e55505941bc8ff3 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 7 Aug 2026 06:35:53 +0000 Subject: [PATCH 3/4] Update README.md --- README.md | 37 ++++--------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 23b4f88..c43c9d2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ --- +## 📖 Documentation + +Full documentation – including all CLI options, tracklist formats, deployment guides, and architecture details – is available in the [**Wiki**](https://git.vmn.su/max/audio_splitter/wiki). + ## ✨ Features - **Flexible tracklist parsing** – Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`) @@ -26,40 +30,7 @@ --- -## 📦 Installation -### Prerequisites - -- **Python 3.8 or higher** -- **FFmpeg** – required for audio processing - -### Install FFmpeg - -| OS | Command | -| -------------------- | ------------------------------------------------------------ | -| **Ubuntu/Debian** | `sudo apt install ffmpeg` | -| **macOS (Homebrew)** | `brew install ffmpeg` | -| **Windows** | Download from [ffmpeg.org](https://ffmpeg.org/download.html) | - -### Install Audio Splitter - -Clone the repository and install: - -```bash -git clone https://git.vmn.su/max/audio_splitter.git -cd audio_splitter -pip install . -``` - -Now the `audio_splitter` command is available globally: - -```bash -audio_splitter input.mp3 tracks.txt -``` - -> **Tip:** For development, install in editable mode: `pip install -e .` - ---- ## 🤝 Contributing -- 2.52.0 From 02b730b0821f565dedacc506cfa88c29df419c05 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Wed, 19 Aug 2026 11:12:48 +0500 Subject: [PATCH 4/4] CHANGE: maps codec/container/file_extension more accurate --- audio_splitter/core.py | 2 +- audio_splitter/ffmpeg.py | 178 +++++++++++++++++++++++--------------- audio_splitter/formats.py | 93 ++++++++++++++++++-- 3 files changed, 192 insertions(+), 81 deletions(-) diff --git a/audio_splitter/core.py b/audio_splitter/core.py index 9839ed3..de5b51e 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -44,7 +44,7 @@ def split_audio(input_file, output_directory, tracks, args): # -------------------------------------------------------------------------- # 2. Format decision # -------------------------------------------------------------------------- - output_format = determine_output_format(stream_info, args.format, args.transcode_to) + output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file) print(f"Output container: {output_format}") validate_format_compatibility(output_format, stream_info, diff --git a/audio_splitter/ffmpeg.py b/audio_splitter/ffmpeg.py index 653decc..e6570ca 100644 --- a/audio_splitter/ffmpeg.py +++ b/audio_splitter/ffmpeg.py @@ -1,7 +1,8 @@ -"""FFmpeg / FFprobe interactions and command building.""" +"""FFmpeg/FFprobe interaction utilities for the CLI and web backend.""" -import subprocess import json +import subprocess +from typing import Dict, List, Optional, Tuple from .constants import FORMAT_INFO from .utils import format_time @@ -24,7 +25,10 @@ def get_audio_duration(input_file: str) -> float: input_file ] result = subprocess.run(cmd, capture_output=True, text=True, check=False) - return float(result.stdout.strip()) + try: + return float(result.stdout.strip()) + except ValueError: + return 0.0 def has_stream_type(input_file: str, stream_type: str) -> bool: @@ -49,7 +53,7 @@ def has_stream_type(input_file: str, stream_type: str) -> bool: return bool(result.stdout.strip()) -def get_audio_codec(input_file: str) -> str: +def get_audio_codec(input_file: str) -> Optional[str]: """ Return the codec name of the first audio stream. @@ -71,7 +75,7 @@ def get_audio_codec(input_file: str) -> str: return codec if codec else None -def get_stream_info(input_file: str): +def get_stream_info(input_file: str) -> Dict[str, any]: """ Collect information about the streams present in the input file. @@ -88,9 +92,97 @@ def get_stream_info(input_file: str): '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): + +def get_metadata(input_file: str) -> Dict[str, any]: + """ + Retrieve metadata from the input file using ffprobe with JSON output. + + Returns a dict with keys: album, title, comments (list of (stream_index, comment)). + """ + 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) + album = None + title = None + comments = [] + + fmt_tags = data.get('format', {}).get('tags', {}) + album = fmt_tags.get('album') or album + title = fmt_tags.get('title') or title + + for idx, stream in enumerate(data.get('streams', [])): + stream_tags = stream.get('tags', {}) + if 'album' in stream_tags: + album = stream_tags['album'] + if 'title' in stream_tags: + title = stream_tags['title'] + 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': []} + + +def get_container_format(input_file: str) -> Optional[str]: + """ + Retrieve the container format name (e.g., 'mp4', 'mp3', 'matroska') from the input file. + + Args: + input_file: Path to the media file. + + Returns: + Container format name (normalized) or None if detection fails. + """ + cmd = [ + 'ffprobe', '-v', 'error', + '-show_entries', 'format=format_name', + '-of', 'default=noprint_wrappers=1:nokey=1', + input_file + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + return None + format_name = result.stdout.strip().split(',')[0] # take first if multiple + if not format_name: + return None + # Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific) + # This mapping is purely for container identification. + mapping = { + 'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container + 'mp2': 'mp3', + 'mp4': 'mp4', + 'm4a': 'mp4', # M4A is MP4 container + 'mov': 'mp4', # QuickTime is MP4-like + '3gp': 'mp4', + 'matroska': 'matroska', + 'webm': 'matroska', # WebM uses Matroska container + 'ogg': 'ogg', + 'flac': 'flac', + 'wav': 'wav', + 'aac': 'aac', + 'opus': 'opus', + 'mp3': 'mp3', + 'adts': 'aac', # raw AAC in ADTS container + 'amr': 'amr', # AMR container (rare) + } + return mapping.get(format_name, format_name) + + +def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int, + output_path: str, stream_info: Dict, format_opt: Optional[str], + transcode_audio: Optional[str], drop_video: bool, drop_subs: bool, + metadata: Optional[Dict] = None) -> List[str]: """ Construct the FFmpeg command line as a list of arguments. @@ -116,18 +208,17 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat '-t', format_time(duration_seconds) ] - # -------------------- Clear all original metadata -------------------- + # Clear all original metadata. cmd.append('-map_metadata') cmd.append('-1') - # -------------------- Apply custom metadata -------------------- + # 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. + # Stream mapping. if drop_video and drop_subs: cmd.extend(['-map', '0:a:0']) elif drop_video: @@ -137,7 +228,7 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat else: cmd.extend(['-map', '0']) - # -------------------- Audio codec -------------------- + # Audio codec. if transcode_audio: cmd.extend(['-c:a', transcode_audio]) if transcode_audio in ('libmp3lame', 'mp3'): @@ -147,77 +238,22 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat else: cmd.extend(['-c:a', 'copy']) - # -------------------- Video codec -------------------- + # Video codec. if not drop_video and stream_info['has_video']: cmd.extend(['-c:v', 'copy']) else: cmd.append('-vn') - # -------------------- Subtitle codec -------------------- + # Subtitle codec. if not drop_subs and stream_info['has_subtitle']: cmd.extend(['-c:s', 'copy']) else: cmd.append('-sn') - # -------------------- Output format -------------------- + # 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': []} - diff --git a/audio_splitter/formats.py b/audio_splitter/formats.py index 26c0d3d..6aaae08 100644 --- a/audio_splitter/formats.py +++ b/audio_splitter/formats.py @@ -1,16 +1,79 @@ """Container format decision and validation.""" +from typing import Dict, Optional + from .constants import FORMAT_INFO -def determine_output_format(stream_info, user_format, transcode_audio): +def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]: + """ + Given the container format and audio codec, determine the recommended output format. + + This is used when the user has not explicitly specified a format. + It prioritizes the codec to choose the most appropriate container/extension. + + Args: + container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format(). + codec: Audio codec name (e.g., 'opus', 'aac', 'mp3') as returned by get_audio_codec(). + + Returns: + Format name (e.g., 'opus', 'm4a', 'mp3') or None if unknown. + """ + if not container: + return None + + # Codec-based decisions (highest priority) + if codec == 'opus': + return 'opus' + if codec in ('aac', 'alac', 'he-aac'): + return 'm4a' + if codec == 'mp3': + return 'mp3' + if codec == 'vorbis': + return 'ogg' + if codec == 'flac': + return 'flac' + + # Container-based fallback (lower priority) + if container in ('mp4', 'm4a', 'mov', '3gp'): + return 'mp4' + if container in ('matroska', 'webm'): + return 'matroska' + if container in ('ogg',): + return 'ogg' + if container in ('mp3', 'mpeg'): + return 'mp3' + if container == 'flac': + return 'flac' + if container == 'wav': + return 'wav' + if container == 'aac': + return 'aac' + if container == 'opus': + return 'opus' + if container == 'amr': + return 'amr' + + return None + + +def determine_output_format(stream_info: Dict, user_format: Optional[str], + transcode_audio: Optional[str], input_file: Optional[str] = None) -> str: """ Decide which container format to use. + If user_format is provided, use it. + Else, try to detect the input file's container and codec, and use the recommended format. + If detection fails or format is not supported, fallback to: + - MKV if video/subtitles exist + - MP3 if the audio codec is MP3 + - MP4 (M4A) otherwise + Args: stream_info: Dict from get_stream_info(). user_format: User‑requested format (or None). - transcode_audio: Audio codec to transcode to (or None). + transcode_audio: Audio codec to transcode to (or None) (unused in this function). + input_file: Path to the input file (optional, used to detect container and codec). Returns: A format name that exists in FORMAT_INFO. @@ -18,11 +81,22 @@ def determine_output_format(stream_info, user_format, transcode_audio): if user_format: return user_format - # If video or subtitles exist, use MKV (which supports everything). - if stream_info['has_video'] or stream_info['has_subtitle']: - return 'matroska' + # If input_file is provided, try to detect container and codec + if input_file: + try: + from .ffmpeg import get_container_format, get_audio_codec + container = get_container_format(input_file) + codec = get_audio_codec(input_file) + fmt = determine_default_format(container, codec) + if fmt in FORMAT_INFO: + return fmt + except Exception: + # If detection fails, fall through to legacy logic + pass - # Audio‑only: choose based on the current audio codec. + # Fallback: legacy behavior + if stream_info.get('has_video') or stream_info.get('has_subtitle'): + return 'matroska' audio_codec = stream_info.get('audio_codec', '') if audio_codec == 'mp3': return 'mp3' @@ -30,7 +104,8 @@ def determine_output_format(stream_info, user_format, transcode_audio): return 'mp4' # .m4a -def validate_format_compatibility(format_name, stream_info, drop_video, drop_subs): +def validate_format_compatibility(format_name: str, stream_info: Dict, + drop_video: bool, drop_subs: bool) -> None: """ Ensure the chosen container can accommodate the streams we intend to keep. @@ -43,12 +118,12 @@ def validate_format_compatibility(format_name, stream_info, drop_video, drop_sub return if info['audio_only']: - if stream_info['has_video'] and not drop_video: + if stream_info.get('has_video') and not drop_video: raise ValueError( f"Format '{format_name}' does not support video streams. " "Please use --drop-video or choose a container that supports video." ) - if stream_info['has_subtitle'] and not drop_subs: + if stream_info.get('has_subtitle') and not drop_subs: raise ValueError( f"Format '{format_name}' does not support subtitle streams. " "Please use --drop-subs or choose a container that supports subtitles." -- 2.52.0