3 Commits

Author SHA1 Message Date
max 539ce3a801 feat (git): refactoring issue template added
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 54s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 58s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-09-02 12:45:38 +05:00
max 3abb66fbdf feat (git): adds feature request issue template 2026-09-02 12:37:08 +05:00
max 8fb68e76ab feat (git): adds a new issue template
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 20m33s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 2m5s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 17s
2026-09-02 12:21:47 +05:00
34 changed files with 981 additions and 1556 deletions
+126
View File
@@ -0,0 +1,126 @@
name: "Bug Report"
description: "Report a bug or unexpected behavior to help us improve"
title: "[BUG]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Before You Begin
Please ensure you have:
- [ ] Searched existing issues to avoid duplicates
- [ ] Confirmed this is a bug, not a question or configuration problem
- type: textarea
id: description
attributes:
label: "📋 Description"
description: "Provide a clear and concise description of the bug"
placeholder: "What happened? What did you expect to happen instead?"
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: "🔁 Steps to Reproduce"
description: "Step-by-step instructions to reproduce the issue"
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: "✅ Expected Behavior"
description: "What you expected to happen"
placeholder: "A clear description of what should happen..."
validations:
required: true
- type: textarea
id: actual
attributes:
label: "❌ Actual Behavior"
description: "What actually happened"
placeholder: "Include error messages, stack traces, or unexpected outcomes..."
validations:
required: true
- type: textarea
id: logs
attributes:
label: "📄 Logs / Screenshots"
description: |
Provide relevant logs, error messages, or screenshots.
For logs, please use a pastebin and share the URL.
**Remember to remove any sensitive information (API keys, passwords, etc.).**[reference:13]
placeholder: "Paste logs here or provide a Gist URL..."
validations:
required: false
- type: markdown
attributes:
value: |
---
## 🖥️ Environment Details
Please fill out the relevant information below.
- type: input
id: version
attributes:
label: "📦 Audio Splitter Version"
description: "The version you are using (or commit reference)"
placeholder: "e.g., v1.21.7"
validations:
required: true
- type: input
id: os
attributes:
label: "💻 Operating System"
description: "Your OS and version"
placeholder: "e.g., Ubuntu 22.04, macOS Sonoma 14.5, Windows"
validations:
required: true
- type: input
id: browser
attributes:
label: "🌍 Browser (if applicable)"
description: "Browser name and version"
placeholder: "e.g., Chrome 120, Firefox 121, Safari 17"
validations:
required: false
- type: textarea
id: additional
attributes:
label: "📎 Additional Context"
description: "Any other information that might be relevant"
placeholder: |
- Database type and version (e.g., PostgreSQL 15, SQLite)
- Reverse proxy/CDN in use (e.g., Nginx, Cloudflare)[reference:15]
- Any custom configuration
- Related issues or PRs
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: "✅ Submission Checklist"
description: "Please confirm the following before submitting"
options:
- label: "I have searched for existing issues (open and closed) that report the same problem"
required: true
- label: "I am using the latest stable release of Audio Splitter"
required: true
- label: "I have provided clear steps to reproduce the issue"
required: true
- label: "I have included relevant logs or error messages (with sensitive info removed)"
required: false
+129
View File
@@ -0,0 +1,129 @@
name: "Feature Request"
description: "Suggest a new feature or enhancement for this project"
title: "[FEATURE]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Before You Begin
Please ensure you have:
- [ ] Searched existing issues (open and closed) to avoid duplicates
- [ ] Reviewed the [project documentation](https://git.vmn.su/max/audio_splitter/wiki) for existing functionality
- [ ] Confirmed this is a feature request, not a bug or configuration question
- type: textarea
id: problem
attributes:
label: "🎯 Problem Statement"
description: "What problem does this feature solve? What can't you do today?"
placeholder: |
I'm always frustrated when...
Currently, it's hard/impossible to...
This feature would help me because...
validations:
required: true
- type: textarea
id: solution
attributes:
label: "💡 Proposed Solution"
description: "Describe the solution you'd like to see"
placeholder: |
A clear description of what you want to happen.
If you have a specific API, UI, or implementation in mind, describe it here.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "🔄 Alternatives Considered"
description: "What alternative solutions or workarounds have you considered?"
placeholder: |
- Alternative A: ...
- Workaround B: ...
- Why these don't fully solve the problem
validations:
required: false
- type: textarea
id: usecase
attributes:
label: "👤 Use Case / Persona"
description: "Who benefits from this feature and in what context?"
placeholder: |
As a [type of user], I want to [do something] so that [I achieve some benefit].
Example: "As a project maintainer, I want to bulk-close issues by label so that I can clean up stale tickets faster."
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: "📸 Screenshots"
description: "If you can, provide screenshots or diagrams of the proposed feature"
placeholder: "Drag and drop images or paste links..."
validations:
required: false
- type: markdown
attributes:
value: |
---
## 🔧 Implementation Details (Optional)
The following sections are for contributors who want to implement this feature.
- type: textarea
id: technical
attributes:
label: "⚙️ Technical Approach"
description: "If you have ideas about how to implement this, share them here"
placeholder: |
- Which components/modules would be affected?
- Any database schema changes?
- API design considerations?
validations:
required: false
- type: textarea
id: acceptance
attributes:
label: "✅ Acceptance Criteria"
description: "What does 'done' look like for this feature?"
placeholder: |
- [ ] Feature works in the UI
- [ ] API endpoints are documented
- [ ] Tests are added
- [ ] Documentation is updated
validations:
required: false
- type: dropdown
id: contribution
attributes:
label: "🤝 Are you willing to contribute this feature?"
description: "Knowing if you can help implement this helps us prioritize"
options:
- "Yes, I can contribute"
- "Yes, but I need guidance"
- "No, but I can test/review"
- "No, I'm just requesting"
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: "✅ Submission Checklist"
description: "Please confirm the following before submitting"
options:
- label: "I have searched for existing issues (open and closed) that request the same feature"
required: true
- label: "I have explained the problem this feature solves"
required: true
- label: "I have described a concrete solution or direction"
required: true
- label: "I have considered alternatives and explained why they fall short"
required: false
+160
View File
@@ -0,0 +1,160 @@
name: "Refactor Request"
description: "Propose a code restructuring or cleanup with no behavior change"
title: "[REFACTOR]: "
labels: ["refactoring"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Before You Begin
Please ensure you have:
- [ ] Searched existing issues (open and closed) to avoid duplicates
- [ ] Reviewed the [project documentation](https://git.vmn.su/max/audio_splitter/wiki) for architecture guidelines
- [ ] Confirmed this is a refactor (code restructuring with NO behavior change), not a bug fix or new feature
- type: textarea
id: problem
attributes:
label: "🎯 Problem Statement"
description: "What pain points does the current code cause? Why is this refactor needed?"
placeholder: |
The current implementation is difficult to maintain because...
This code has accumulated technical debt due to...
I'm constantly frustrated when working with this module because...
validations:
required: true
- type: textarea
id: scope
attributes:
label: "📂 Scope"
description: "Which files, modules, or components need refactoring? Be specific."
placeholder: |
- File paths: `src/auth/`, `internal/handler/`
- Component: `UserService` class
validations:
required: true
- type: textarea
id: current-state
attributes:
label: "🔍 Current State"
description: "Describe the current code structure, its issues, and why it's problematic"
placeholder: |
- The function `processUser()` is 500+ lines long
- Duplicated logic across 3 different files
- Mixed concerns (business logic + HTTP handling)
- Poor test coverage makes changes risky
- Naming is unclear and inconsistent
validations:
required: true
- type: textarea
id: proposed-changes
attributes:
label: "💡 Proposed Solution"
description: "What should the code look like after the refactor?"
placeholder: |
- Extract `processUser()` into smaller, focused functions
- Consolidate duplicated logic into a shared utility
- Separate business logic from HTTP handlers
- Introduce interfaces for better testability
- Rename variables and functions for clarity
validations:
required: true
- type: textarea
id: constraints
attributes:
label: "🚫 Constraints"
description: "What must NOT change? Public APIs, behavior, backward compatibility, performance characteristics?"
placeholder: |
- All public APIs must remain identical
- Database schema must not change
- External behavior must be identical
- Response formats must stay the same
- Performance must not degrade
validations:
required: true
- type: textarea
id: benefits
attributes:
label: "📈 Expected Benefits"
description: "What improvements will this refactor bring?"
placeholder: |
- Improved maintainability
- Better testability
- Reduced code duplication
- Clearer separation of concerns
- Easier onboarding for new contributors
validations:
required: false
- type: textarea
id: risks
attributes:
label: "⚠️ Risks & Mitigations"
description: "What could go wrong, and how will you mitigate it?"
placeholder: |
- Risk: Regression bugs
Mitigation: Comprehensive test coverage before and after
- Risk: Large diff making code review difficult
Mitigation: Break into smaller, incremental PRs
validations:
required: false
- type: textarea
id: testing
attributes:
label: "🧪 Testing Strategy"
description: "How will you ensure the refactor doesn't break existing functionality?"
placeholder: |
- Existing test suite must pass
- Add regression tests before refactoring
- Run benchmarks to ensure no performance regression
- Manual testing of critical paths
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "🔄 Alternatives Considered"
description: "What other approaches did you consider, and why did you reject them?"
placeholder: |
- Alternative A: Complete rewrite — rejected because too risky
- Alternative B: Gradual deprecation — rejected because too slow
validations:
required: false
- type: dropdown
id: contribution
attributes:
label: "🤝 Are you willing to contribute this refactor?"
description: "Knowing if you can help implement this helps us prioritize"
options:
- "Yes, I can contribute"
- "Yes, but I need guidance"
- "No, but I can review"
- "No, I'm just suggesting"
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: "✅ Submission Checklist"
description: "Please confirm the following before submitting"
options:
- label: "I have searched for existing issues (open and closed) that request the same refactor"
required: true
- label: "I have clearly explained the problem this refactor solves"
required: true
- label: "I have described the scope of the refactor (specific files/modules)"
required: true
- label: "I have identified what must NOT change (APIs, behavior, backward compatibility)"
required: true
- label: "I have described how the refactor will be tested"
required: true
+57
View File
@@ -0,0 +1,57 @@
FROM python:3.13-slim
# Install FFmpeg and dependencies required for gosu installation
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
wget \
gnupg \
dirmngr \
gnupg-agent && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install gosu (lightweight tool for dropping privileges)
RUN set -eux; \
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch.asc"; \
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
chmod +x /usr/local/bin/gosu; \
gosu --version
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /app
# Copy package metadata and source code
COPY setup.py pyproject.toml README.md ./
COPY audio_splitter/ ./audio_splitter/
# Install the package
RUN pip install --no-cache-dir .
# Create a non-root user with UID 1000
RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser
# Change ownership of /app to the container user (so it can write there if needed)
RUN chown -R appuser:appgroup /app
# Copy the entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set the entrypoint
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Default command (shows help if no arguments provided)
CMD ["--help"]
+16 -131
View File
@@ -1,135 +1,20 @@
# audio_splitter/constants.py """Global constants and default values."""
"""Global constants for the audio splitter.
This file serves as the single source of truth for: # Mapping from userfriendly format names to FFmpeg format identifiers,
- Container formats and their properties # file extensions, and whether the container is audioonly.
- Audio codecs and their recommended containers
- Video codec support per container
- File extensions for each codec/container combination
- Compatibility matrix for codec/container validation
Codec != Container != File Extension.
Example: Opus (codec) → Ogg (container) → .opus (extension)
"""
# ------------------------------------------------------------------------------
# Container information
# ------------------------------------------------------------------------------
CONTAINER_INFO = [
# Audio-only containers
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
# Containers that support video and subtitles
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'matroska', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False},
]
# Legacy FORMAT_INFO for backward compatibility
FORMAT_INFO = { FORMAT_INFO = {
container['name']: { 'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'ffmpeg': container['ffmpeg'], 'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
'ext': container['extension'], 'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
'audio_only': container['audio_only'], 'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
} 'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
for container in CONTAINER_INFO 'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
} }
# ------------------------------------------------------------------------------ # Default characters to replace when --replace-bad-chars is enabled.
# Codec information # Includes common punctuation, single quote, and a trailing space.
# ------------------------------------------------------------------------------ DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' '
CODEC_INFO = [
# Audio codecs
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False},
# Video codecs
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'supports_transcoding': False, 'supports_video': True},
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'supports_transcoding': False, 'supports_video': True},
]
# Map codec name → recommended container
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO}
# Map codec name → FFmpeg encoder name
CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO}
# ------------------------------------------------------------------------------
# Video codec support per container (legacy, will be superseded by compatibility matrix)
# ------------------------------------------------------------------------------
CONTAINER_VIDEO_CODEC_SUPPORT = {
'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'],
'mkv': ['*'],
'matroska': ['*'],
'ogg': ['theora', 'dirac', 'vp8', 'png', 'mjpeg'],
'webm': ['vp8', 'vp9', 'av1'],
'mp3': [],
'm4a': [],
'flac': [],
'wav': [],
'aac': [],
}
# ------------------------------------------------------------------------------
# Compatibility matrix: container → supported audio and video codecs
# ------------------------------------------------------------------------------
# Each container entry maps to a dict with 'audio' and 'video' keys.
# - 'audio': list of audio codec names that are supported in this container.
# Use None to indicate that any audio codec is supported.
# - 'video': list of video codec names that are supported in this container.
# Use None to indicate that any video codec is supported.
# Empty list means no video support (audio-only container).
COMPATIBILITY_MATRIX = {
'mp3': {'audio': ['mp3'], 'video': []},
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
'mkv': {'audio': None, 'video': None},
'matroska': {'audio': None, 'video': None},
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8']},
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
'flac': {'audio': ['flac'], 'video': []},
'wav': {'audio': ['pcm_s16le'], 'video': []},
'aac': {'audio': ['aac'], 'video': []},
}
# ------------------------------------------------------------------------------
# Helper functions for compatibility checking
# ------------------------------------------------------------------------------
def is_audio_codec_supported(container: str, audio_codec: str) -> bool:
"""Check if an audio codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('audio')
if supported is None:
return True
return audio_codec in supported
def is_video_codec_supported(container: str, video_codec: str) -> bool:
"""Check if a video codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('video')
if supported is None:
return True
return video_codec in supported
# ------------------------------------------------------------------------------
# Default bad characters (for filename sanitization)
# ------------------------------------------------------------------------------
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
+29 -146
View File
@@ -1,68 +1,30 @@
# audio_splitter/core.py """Core logic: orchestrates the splitting process."""
"""Core splitting logic orchestrates the entire split process."""
import os import os
import subprocess import subprocess
import sys
from typing import List, Dict, Any
from .constants import ( from .constants import FORMAT_INFO
FORMAT_INFO, from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command
CODEC_TO_EXTENSION_MAP, from .formats import determine_output_format, validate_format_compatibility
CODEC_NAME_TO_FFMPEG, # <-- Add this
)
from .ffmpeg import (
get_audio_duration,
get_stream_info,
get_metadata,
build_ffmpeg_command,
is_attached_picture,
extract_cover_image,
)
from .formats import (
determine_output_format,
validate_format_compatibility,
)
from .timestamp import parse_track_timestamps, resolve_end_times from .timestamp import parse_track_timestamps, resolve_end_times
from .filename import build_filename from .filename import build_filename
from .metadata import build_metadata_dict from .metadata import build_metadata_dict
from .utils import format_time from .utils import format_time
def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, Any]], args) -> None: def split_audio(input_file, output_directory, tracks, args):
""" """
Main orchestration function: split the audio file into tracks. Main orchestration function: split the audio file into tracks.
Args: Args:
input_file: Path to the input media file. input_file: Path to the input media file.
output_directory: Directory where output files will be saved. output_directory: Directory where output files will be saved.
tracks: List of dicts, each containing parsed fields (ts, tn, an, ...). tracks: List of dicts, each containing parsed fields.
args: Parsed commandline arguments (namespace) with attributes: args: Parsed commandline arguments (namespace).
- container: output container name (or None for auto-detect)
- audio_codec: audio codec (copy or encoder)
- video_codec: video codec (copy or encoder)
- subtitle_codec: subtitle codec (copy or encoder)
- video_quality: integer or None
- drop_video: bool
- drop_subs: bool
- number_tracks: bool
- output_template: str
- replace_bad_chars: bool
- replacement_char: str
- bad_chars: str
- skip_existing: bool
- album: str or None
- comment: str or None
- no_comment: bool
- comment_stream: int or None
- merge_comments: bool
- comment_separator: str
- delete_original: bool
- cover_image: str or None (optional, set by web backend)
Raises: Raises:
RuntimeError: If no audio stream is found. RuntimeError: If no audio stream is found.
ValueError: If compatibility validation fails. ValueError: If timestamp parsing or format compatibility fails.
""" """
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 1. Setup # 1. Setup
@@ -80,58 +42,26 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
raise RuntimeError("No audio stream found in input file.") raise RuntimeError("No audio stream found in input file.")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 2. Determine output container # 2. Format decision
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
user_container = getattr(args, 'container', None) output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file)
output_container = determine_output_format( print(f"Output container: {output_format}")
stream_info,
user_format=user_container, validate_format_compatibility(output_format, stream_info,
transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None, args.drop_video, args.drop_subs)
input_file=input_file
) # Determine file extension.
print(f"Output container: {output_container}") extension_info = FORMAT_INFO.get(output_format, {})
extension = extension_info.get('ext', '.mkv')
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 3. Validate compatibility # 3. Parse timestamps
# --------------------------------------------------------------------------
try:
validate_format_compatibility(
container=output_container,
stream_info=stream_info,
drop_video=args.drop_video,
drop_subs=args.drop_subs,
input_file=input_file,
audio_codec=args.audio_codec,
video_codec=args.video_codec,
)
except ValueError as e:
raise RuntimeError(f"Compatibility error: {e}")
# --------------------------------------------------------------------------
# 4. Determine output audio codec and extension
# --------------------------------------------------------------------------
# Determine the audio codec that will be used in the output
if args.audio_codec != 'copy':
output_audio_codec = args.audio_codec
else:
output_audio_codec = stream_info.get('audio_codec', '')
# Choose extension based on audio codec if possible, otherwise fallback to container default
if output_audio_codec in CODEC_TO_EXTENSION_MAP:
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
else:
extension = FORMAT_INFO.get(output_container, {}).get('ext', '.mkv')
print(f"Output extension: {extension}")
# --------------------------------------------------------------------------
# 5. Parse timestamps
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
track_times = parse_track_timestamps(tracks) track_times = parse_track_timestamps(tracks)
resolved_times = resolve_end_times(track_times, total_duration) resolved_times = resolve_end_times(track_times, total_duration)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 6. Fetch original metadata (for fallbacks) # 4. Fetch original metadata (for fallbacks)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
input_metadata = get_metadata(input_file) input_metadata = get_metadata(input_file)
original_album = input_metadata.get('album') original_album = input_metadata.get('album')
@@ -148,22 +78,7 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
print(f" Stream {idx}: '{comment}'") print(f" Stream {idx}: '{comment}'")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 7. Handle attached picture (cover art) # 5. Process each track
# --------------------------------------------------------------------------
cover_image_path = getattr(args, 'cover_image', None)
if cover_image_path and not os.path.exists(cover_image_path):
cover_image_path = None
if not cover_image_path and not args.drop_video and stream_info.get('has_video'):
if is_attached_picture(input_file):
cover_image_path = os.path.join(output_directory, 'cover.png')
if extract_cover_image(input_file, cover_image_path):
print("Extracted cover image for all tracks.")
else:
cover_image_path = None
# --------------------------------------------------------------------------
# 8. Process each track
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
for idx, track in enumerate(tracks, start=1): for idx, track in enumerate(tracks, start=1):
start_seconds, end_seconds = resolved_times[idx - 1] start_seconds, end_seconds = resolved_times[idx - 1]
@@ -176,53 +91,31 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
track_name = track.get('tn', 'Unknown') track_name = track.get('tn', 'Unknown')
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8a. Build filename # 5a. Build filename
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
clean_filename = build_filename(track, idx, extension, args) clean_filename = build_filename(track, idx, extension, args)
output_path = os.path.join(output_directory, clean_filename) output_path = os.path.join(output_directory, clean_filename)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8b. Handle existing files # 5b. Handle existing files
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
if args.skip_existing and os.path.exists(output_path): if args.skip_existing and os.path.exists(output_path):
print(f"Skipping track {idx}: {output_path} already exists.") print(f"Skipping track {idx}: {output_path} already exists.")
continue continue
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8c. Build metadata # 5c. Build metadata
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
metadata = build_metadata_dict(track, idx, input_metadata, args) metadata = build_metadata_dict(track, idx, input_metadata, args)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8d. Build and execute FFmpeg command # 5d. Build and execute FFmpeg command
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Map codec names to FFmpeg encoder names
audio_enc = args.audio_codec
if audio_enc != 'copy':
audio_enc = CODEC_NAME_TO_FFMPEG.get(audio_enc, audio_enc) # fallback to itself if not found
video_enc = args.video_codec
if video_enc != 'copy':
video_enc = CODEC_NAME_TO_FFMPEG.get(video_enc, video_enc)
subtitle_enc = args.subtitle_codec
if subtitle_enc != 'copy':
subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc)
# Then build command with these mapped encoders
cmd = build_ffmpeg_command( cmd = build_ffmpeg_command(
input_file=input_file, input_file, start_seconds, duration_seconds, output_path,
start_seconds=start_seconds, stream_info, output_format, args.transcode_to,
duration_seconds=duration_seconds, args.drop_video, args.drop_subs,
output_path=output_path, metadata=metadata
stream_info=stream_info,
format_opt=output_container,
audio_codec=audio_enc,
video_codec=video_enc,
subtitle_codec=subtitle_enc,
metadata=metadata,
cover_image_path=cover_image_path,
video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video,
drop_subs=args.drop_subs,
) )
print(f"Extracting track {idx}: {track_name} " print(f"Extracting track {idx}: {track_name} "
@@ -235,13 +128,3 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
print(result.stderr) print(result.stderr)
else: else:
print(f" -> Saved to: {output_path}") print(f" -> Saved to: {output_path}")
# --------------------------------------------------------------------------
# 9. Delete original file if requested
# --------------------------------------------------------------------------
if getattr(args, 'delete_original', False):
try:
os.remove(input_file)
print(f"Deleted original file: {input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
-35
View File
@@ -1,35 +0,0 @@
"""Default values for all configurable options shared between CLI, web backend, and frontend."""
# Output container format (used when user doesn't specify one; auto-detection overrides this)
DEFAULT_FORMAT = "mp3"
# Filename template
DEFAULT_OUTPUT_TEMPLATE = "%an-%tn.%ext"
# Character replacement
DEFAULT_REPLACEMENT_CHAR = "_"
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
# Tracklist parsing
DEFAULT_TRACKLIST_FORMAT = "%ts %tn - %an"
# Metadata defaults
DEFAULT_ALBUM = None
DEFAULT_COMMENT = None
DEFAULT_COMMENT_STREAM = None
DEFAULT_COMMENT_SEPARATOR = "; "
DEFAULT_NO_COMMENT = False
DEFAULT_MERGE_COMMENTS = False
# Stream handling
DEFAULT_DROP_VIDEO = False
DEFAULT_DROP_SUBS = False
# Filename/export options
DEFAULT_NUMBER_TRACKS = False
DEFAULT_REPLACE_BAD_CHARS = True
DEFAULT_SKIP_EXISTING = False
DEFAULT_DELETE_ORIGINAL = False
# Transcoding (None means copy codec)
DEFAULT_TRANSCODE_TO = None
+67 -240
View File
@@ -1,5 +1,4 @@
# audio_splitter/ffmpeg.py """FFmpeg/FFprobe interaction utilities for the CLI and web backend."""
"""FFmpeg/FFprobe interaction utilities."""
import json import json
import subprocess import subprocess
@@ -76,28 +75,6 @@ def get_audio_codec(input_file: str) -> Optional[str]:
return codec if codec else None return codec if codec else None
def get_video_codec(input_file: str) -> Optional[str]:
"""
Return the codec name of the first video stream.
Args:
input_file: Path to the media file.
Returns:
Codec name as a lowercase string, or None if no video stream exists.
"""
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v: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) -> Dict[str, any]: def get_stream_info(input_file: str) -> Dict[str, any]:
""" """
Collect information about the streams present in the input file. Collect information about the streams present in the input file.
@@ -179,215 +156,33 @@ def get_container_format(input_file: str) -> Optional[str]:
format_name = result.stdout.strip().split(',')[0] # take first if multiple format_name = result.stdout.strip().split(',')[0] # take first if multiple
if not format_name: if not format_name:
return None return None
# Normalize common aliases to names used in FORMAT_INFO # Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific)
# This mapping is purely for container identification.
mapping = { mapping = {
'mpeg': 'mp3', 'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container
'mp2': 'mp3', 'mp2': 'mp3',
'mp4': 'mp4', 'mp4': 'mp4',
'm4a': 'mp4', 'm4a': 'mp4', # M4A is MP4 container
'mov': 'mp4', 'mov': 'mp4', # QuickTime is MP4-like
'3gp': 'mp4', '3gp': 'mp4',
'matroska': 'matroska', 'matroska': 'matroska',
'webm': 'matroska', 'webm': 'matroska', # WebM uses Matroska container
'ogg': 'ogg', 'ogg': 'ogg',
'flac': 'flac', 'flac': 'flac',
'wav': 'wav', 'wav': 'wav',
'aac': 'aac', 'aac': 'aac',
'opus': 'opus', 'opus': 'opus',
'mp3': 'mp3', 'mp3': 'mp3',
'adts': 'aac', 'adts': 'aac', # raw AAC in ADTS container
'amr': 'amr', 'amr': 'amr', # AMR container (rare)
} }
return mapping.get(format_name, format_name) return mapping.get(format_name, format_name)
def is_attached_picture(input_file: str) -> bool: def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int,
""" output_path: str, stream_info: Dict, format_opt: Optional[str],
Check if the input file has a video stream that is an attached picture (cover art). transcode_audio: Optional[str], drop_video: bool, drop_subs: bool,
metadata: Optional[Dict] = None) -> List[str]:
Detection logic:
1. If there is a video stream with disposition.attached_pic == 1, return True.
2. Otherwise, if there is exactly one video stream and its codec is an image
format (PNG, MJPEG, JPEG, GIF, BMP), return True.
Returns:
True if a cover image is detected, False otherwise.
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-select_streams', 'v',
'-show_entries', 'stream=codec_name,disposition',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
if not streams:
return False
# Check each stream
for stream in streams:
codec = stream.get('codec_name', '').lower()
disposition = stream.get('disposition', {})
# If attached_pic is set, it's a cover image
if disposition.get('attached_pic') == 1:
return True
# If not, check if it's an image codec and we have exactly one video stream
if codec in ('png', 'mjpeg', 'jpeg', 'gif', 'bmp'):
# If there is exactly one video stream, treat it as cover
if len(streams) == 1:
return True
return False
except (json.JSONDecodeError, KeyError):
return False
def extract_cover_image(input_file: str, output_path: str) -> bool:
"""
Extract the first frame of the video stream (assumed to be an attached picture)
and save it to output_path.
Returns:
True if extraction succeeded, False otherwise.
"""
cmd = [
'ffmpeg', '-i', input_file,
'-map', '0:v:0',
'-frames:v', '1',
'-y',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"Failed to extract cover image: {result.stderr}")
return False
return True
def _build_inputs(cmd: List[str], input_file: str, cover_image_path: Optional[str]) -> None:
"""Add input files to the command."""
if cover_image_path:
cmd.extend(['-i', cover_image_path])
cmd.extend(['-i', input_file])
def _build_time_options(cmd: List[str], start_seconds: int, duration_seconds: int) -> None:
"""Add time-based options to the command."""
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
def _build_metadata(cmd: List[str], metadata: Optional[Dict]) -> None:
"""Add metadata options to the command."""
cmd.append('-map_metadata')
cmd.append('-1')
if metadata:
for key, value in metadata.items():
if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"])
def _build_cover_image_mapping(
cmd: List[str],
audio_codec: Optional[str],
video_codec: Optional[str],
video_quality: Optional[int],
) -> None:
"""Build stream mapping for cover image extraction (audio + cover video)."""
cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
else:
cmd.extend(['-c:v', 'png'])
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
cmd.append('-sn')
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
def _build_standard_mapping(
cmd: List[str],
stream_info: Dict,
audio_codec: Optional[str],
video_codec: Optional[str],
subtitle_codec: Optional[str],
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> None:
"""Build stream mapping for standard extraction (no cover image)."""
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'])
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
if not drop_video and stream_info.get('has_video'):
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
cmd.extend(['-c:v', 'copy'])
else:
cmd.append('-vn')
if not drop_subs and stream_info.get('has_subtitle'):
if subtitle_codec and subtitle_codec != 'copy':
cmd.extend(['-c:s', subtitle_codec])
else:
cmd.extend(['-c:s', 'copy'])
else:
cmd.append('-sn')
def _build_format(cmd: List[str], format_opt: Optional[str]) -> None:
"""Add output format option if specified."""
if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format])
def _build_output(cmd: List[str], output_path: str) -> None:
"""Add output file to the command."""
cmd.extend(['-y', output_path])
def build_ffmpeg_command(
input_file: str,
start_seconds: int,
duration_seconds: int,
output_path: str,
stream_info: Dict,
format_opt: Optional[str],
audio_codec: Optional[str] = 'copy',
video_codec: Optional[str] = 'copy',
subtitle_codec: Optional[str] = 'copy',
metadata: Optional[Dict] = None,
cover_image_path: Optional[str] = None,
video_quality: Optional[int] = None,
drop_video: bool = False,
drop_subs: bool = False,
) -> List[str]:
""" """
Construct the FFmpeg command line as a list of arguments. Construct the FFmpeg command line as a list of arguments.
@@ -397,36 +192,68 @@ def build_ffmpeg_command(
duration_seconds: Duration of the segment (in seconds). duration_seconds: Duration of the segment (in seconds).
output_path: Destination path for the output file. output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info(). stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3', 'mkv'). format_opt: Output container format (e.g., 'mp3').
audio_codec: Audio codec to use ('copy' or encoder name like 'libopus'). transcode_audio: Audio codec to transcode to (or None).
video_codec: Video codec to use ('copy' or encoder name). drop_video: True to remove video streams.
subtitle_codec: Subtitle codec to use ('copy' or encoder name). drop_subs: True to remove subtitle streams.
metadata: Optional dict of metadata key/value pairs to write. metadata: Optional dict of metadata key/value pairs to write.
cover_image_path: Path to extracted cover image (if any).
video_quality: Quality value for video encoder (e.g., 1-31, lower=better).
drop_video: If True, remove video streams.
drop_subs: If True, remove subtitle streams.
Returns: Returns:
A list of commandline arguments suitable for subprocess.run(). A list of commandline arguments suitable for subprocess.run().
""" """
cmd = ['ffmpeg'] cmd = [
'ffmpeg',
'-i', input_file,
'-ss', format_time(start_seconds),
'-t', format_time(duration_seconds)
]
_build_inputs(cmd, input_file, cover_image_path) # Clear all original metadata.
_build_time_options(cmd, start_seconds, duration_seconds) cmd.append('-map_metadata')
_build_metadata(cmd, metadata) cmd.append('-1')
if cover_image_path: # Apply custom metadata.
_build_cover_image_mapping(cmd, audio_codec, video_codec, video_quality) if metadata:
for key, value in metadata.items():
if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"])
# Stream mapping.
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: else:
_build_standard_mapping( cmd.extend(['-map', '0'])
cmd, stream_info, audio_codec, video_codec, subtitle_codec,
video_quality, drop_video, drop_subs
)
_build_format(cmd, format_opt) # Audio codec.
_build_output(cmd, output_path) 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])
cmd.extend(['-y', output_path])
return cmd return cmd
+63 -60
View File
@@ -1,25 +1,16 @@
# audio_splitter/formats.py
"""Container format decision and validation.""" """Container format decision and validation."""
from typing import Dict, Optional from typing import Dict, Optional
from .constants import ( from .constants import FORMAT_INFO
FORMAT_INFO,
CONTAINER_INFO,
CODEC_TO_CONTAINER_MAP,
COMPATIBILITY_MATRIX,
is_audio_codec_supported,
is_video_codec_supported,
)
from .defaults import DEFAULT_FORMAT
def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]: def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
""" """
Given the container format and audio codec, determine the recommended output format. Given the container format and audio codec, determine the recommended output format.
Uses the CODEC_TO_CONTAINER_MAP to map codec → container. This is used when the user has not explicitly specified a format.
If the codec is not found, it falls back to the container. It prioritizes the codec to choose the most appropriate container/extension.
Args: Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format(). container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
@@ -31,13 +22,37 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
if not container: if not container:
return None return None
if codec and codec in CODEC_TO_CONTAINER_MAP: # Codec-based decisions (highest priority)
fmt = CODEC_TO_CONTAINER_MAP[codec] if codec == 'opus':
if fmt in FORMAT_INFO: return 'opus'
return fmt if codec in ('aac', 'alac', 'he-aac'):
return 'm4a'
if codec == 'mp3':
return 'mp3'
if codec == 'vorbis':
return 'ogg'
if codec == 'flac':
return 'flac'
if container in FORMAT_INFO: # Container-based fallback (lower priority)
return container 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 return None
@@ -53,75 +68,63 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str],
- MKV if video/subtitles exist - MKV if video/subtitles exist
- MP3 if the audio codec is MP3 - MP3 if the audio codec is MP3
- MP4 (M4A) otherwise - MP4 (M4A) otherwise
Args:
stream_info: Dict from get_stream_info().
user_format: Userrequested format (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.
""" """
if user_format: if user_format:
return user_format return user_format
# If input_file is provided, try to detect container and codec
if input_file: if input_file:
try: try:
from .ffmpeg import get_container_format, get_audio_codec from .ffmpeg import get_container_format, get_audio_codec
container = get_container_format(input_file) container = get_container_format(input_file)
audio_codec = get_audio_codec(input_file) codec = get_audio_codec(input_file)
fmt = determine_default_format(container, audio_codec) fmt = determine_default_format(container, codec)
if fmt in FORMAT_INFO: if fmt in FORMAT_INFO:
return fmt return fmt
except Exception: except Exception:
# If detection fails, fall through to legacy logic
pass pass
# Fallback # Fallback: legacy behavior
if stream_info.get('has_video') or stream_info.get('has_subtitle'): if stream_info.get('has_video') or stream_info.get('has_subtitle'):
return 'matroska' return 'matroska'
audio_codec = stream_info.get('audio_codec', '') audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3': if audio_codec == 'mp3':
return 'mp3' return 'mp3'
else: else:
return 'mp4' return 'mp4' # .m4a
def validate_format_compatibility( def validate_format_compatibility(format_name: str, stream_info: Dict,
container: str, drop_video: bool, drop_subs: bool) -> None:
stream_info: Dict,
drop_video: bool,
drop_subs: bool,
input_file: Optional[str] = None,
audio_codec: Optional[str] = None,
video_codec: Optional[str] = None,
) -> None:
""" """
Ensure the chosen container and codec combination is valid. Ensure the chosen container can accommodate the streams we intend to keep.
Raises: Raises:
ValueError: If the combination is incompatible. ValueError: If the format is incompatible with the intended streams.
""" """
info = FORMAT_INFO.get(container) info = FORMAT_INFO.get(format_name)
if not info: if not info:
print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.") print(f"Warning: Unknown format '{format_name}'. Proceeding, but may fail.")
return return
# Check if container is audio-only and video is present (unless dropped) if info['audio_only']:
if info['audio_only'] and stream_info.get('has_video') and not drop_video: if stream_info.get('has_video') and not drop_video:
raise ValueError(
f"Container '{container}' does not support video streams. "
"Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)."
)
# Check if audio codec is supported
if audio_codec and audio_codec != 'copy':
if not is_audio_codec_supported(container, audio_codec):
raise ValueError( raise ValueError(
f"Container '{container}' does not support audio codec '{audio_codec}'. " f"Format '{format_name}' does not support video streams. "
f"Please choose a different container or audio codec." "Please use --drop-video or choose a container that supports video."
)
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."
) )
# Check if video codec is supported (if video is present and not dropped)
if stream_info.get('has_video') and not drop_video:
# Determine the video codec from input file if not provided
if video_codec is None and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
if video_codec and video_codec != 'copy':
if not is_video_codec_supported(container, video_codec):
raise ValueError(
f"Container '{container}' does not support video codec '{video_codec}'. "
f"Please choose a different container, drop video, or transcode video to a supported codec."
)
+100 -125
View File
@@ -1,182 +1,139 @@
#!/usr/bin/env python3 """Commandline interface and entry point."""
"""
Audio Splitter CommandLine Interface
Split an audio file into tracks using a tracklist file.
Usage:
audio_splitter input.mp3 tracklist.txt [OPTIONS]
"""
import os import os
import sys import sys
import subprocess import subprocess
import argparse import argparse
from .defaults import ( from .constants import DEFAULT_BAD_CHARS
DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_TRACKLIST_FORMAT,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_NO_COMMENT,
DEFAULT_MERGE_COMMENTS,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_DELETE_ORIGINAL,
)
from .core import split_audio
from .tracklist import read_tracklist, parse_format from .tracklist import read_tracklist, parse_format
from .core import split_audio
def main(): def main():
"""Parse arguments and start the splitting process."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Split an audio file into tracks using a tracklist.", description="Split an audio file into tracks using a tracklist.",
epilog="Tracklist format: mm:ss track_name - author_name (or custom with --tracklist-format)" epilog="Tracklist format: mm:ss track_name - author_name"
) )
# Positional # Positional arguments.
parser.add_argument('input_file', help='Input audio file') parser.add_argument('input_file', help='Input audio/video file')
parser.add_argument('tracklist_file', help='Tracklist file') parser.add_argument('tracklist_file', help='Tracklist file')
# Container and codec options # Optional arguments.
parser.add_argument( parser.add_argument(
'--container', '--output-dir', '-o',
default=None, help='Output directory for split tracks (default: <input_basename>_splits)'
help="Output container format (auto-detect if not specified)"
) )
parser.add_argument( parser.add_argument(
'--audio-codec', '--format',
default='copy', help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)'
help="Audio codec (copy or encoder name, e.g., libopus)"
) )
parser.add_argument( parser.add_argument(
'--video-codec', '--transcode-to',
default='copy', help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)'
help="Video codec (copy or encoder name, e.g., libx264)"
)
parser.add_argument(
'--subtitle-codec',
default='copy',
help="Subtitle codec (copy or encoder name, e.g., srt)"
)
parser.add_argument(
'--video-quality', '-vq',
type=int,
default=None,
help="Video quality (integer, encoder-specific; usually 1-31, lower=better). "
"If omitted, FFmpeg default is used."
) )
parser.add_argument( parser.add_argument(
'--drop-video', '--drop-video',
action='store_true', action='store_true',
default=DEFAULT_DROP_VIDEO, help='Remove video streams from output'
help="Drop video streams"
) )
parser.add_argument( parser.add_argument(
'--drop-subs', '--drop-subs',
action='store_true', action='store_true',
default=DEFAULT_DROP_SUBS, help='Remove subtitle streams from output'
help="Drop subtitle streams"
) )
# Filename options
parser.add_argument( parser.add_argument(
'--number-tracks', '--number-tracks',
action='store_true', action='store_true',
default=DEFAULT_NUMBER_TRACKS, help='Prepend track number to output filenames (convenience; use %%num in template for full control)'
help="Prepend track numbers"
)
parser.add_argument(
'--output-template',
default=DEFAULT_OUTPUT_TEMPLATE,
help="Output filename template (default: %(default)s)"
) )
parser.add_argument( parser.add_argument(
'--replace-bad-chars', '--replace-bad-chars',
action='store_true', action='store_true',
default=DEFAULT_REPLACE_BAD_CHARS, help='Replace problematic characters in filenames (default: off)'
help="Replace bad characters"
) )
parser.add_argument( parser.add_argument(
'--replacement-char', '--replacement-char',
default=DEFAULT_REPLACEMENT_CHAR, default='_',
help="Replacement character (default: %(default)s)" help='Character used as replacement (default: "_")'
) )
parser.add_argument( parser.add_argument(
'--bad-chars', '--bad-chars',
default=DEFAULT_BAD_CHARS, default=DEFAULT_BAD_CHARS,
help="Bad characters to replace (default: %(default)s)" help='String of characters to replace (default includes space and single quote)'
) )
parser.add_argument( parser.add_argument(
'--skip-existing', '--skip-existing',
action='store_true', action='store_true',
default=DEFAULT_SKIP_EXISTING, help='Skip extraction if output file already exists (default: overwrite)'
help="Skip existing output files"
) )
# Metadata
parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
parser.add_argument(
'--no-comment',
action='store_true',
default=DEFAULT_NO_COMMENT,
help="Ignore comment"
)
parser.add_argument(
'--comment-stream',
type=int,
default=DEFAULT_COMMENT_STREAM,
help="Comment stream index"
)
parser.add_argument(
'--merge-comments',
action='store_true',
default=DEFAULT_MERGE_COMMENTS,
help="Merge all comments"
)
parser.add_argument(
'--comment-separator',
default=DEFAULT_COMMENT_SEPARATOR,
help="Separator for merged comments (default: %(default)s)"
)
# Tracklist format
parser.add_argument( parser.add_argument(
'--tracklist-format', '--tracklist-format',
default=DEFAULT_TRACKLIST_FORMAT, default='%ts %tn - %an',
help="Tracklist format (default: %(default)s)" help='Format of each line in the tracklist using placeholders: '
'%%ts (timestamp), %%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension). '
'Default: "%%ts %%tn - %%an"'
) )
# Other
parser.add_argument( parser.add_argument(
'--delete-original', '--output-template',
action='store_true', default='%an-%tn.%ext',
default=DEFAULT_DELETE_ORIGINAL, help='Template for output filenames using placeholders: '
help="Delete original file after split" '%%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension), %%num (track number). '
'Default: "%%an-%%tn.%%ext"'
) )
parser.add_argument( parser.add_argument(
'--dry-run', '--dry-run',
action='store_true', action='store_true',
help="Parse and display tracklist without splitting" help='Parse and display the tracklist without splitting any files'
)
# Metadata options.
parser.add_argument(
'--album',
help='Set album name in output metadata (overrides parsed %%al and original album)'
) )
parser.add_argument( parser.add_argument(
'--output-dir', '-o', '--comment',
help='Explicit comment text (overrides all other comment settings)'
)
parser.add_argument(
'--no-comment',
action='store_true',
help='Explicitly ignore any comment (no comment written)'
)
parser.add_argument(
'--comment-stream',
type=int,
default=None, default=None,
help="Output directory (default: <input_basename>_splits)" help='Select comment from a specific stream index (0based). Default: first stream with a comment.'
)
parser.add_argument(
'--merge-comments',
action='store_true',
help='Merge all comments from all streams into one (separated by --comment-separator)'
)
parser.add_argument(
'--comment-separator',
default='; ',
help='Separator used when merging comments (default: "; ")'
)
# NEW: Delete original after successful split.
parser.add_argument(
'--delete-original',
action='store_true',
help='Delete the original input file after successful splitting (default: keep)'
) )
args = parser.parse_args() args = parser.parse_args()
# Validate input # --------------------------------------------------------------------------
# Input validation
# --------------------------------------------------------------------------
if not os.path.exists(args.input_file): if not os.path.exists(args.input_file):
print(f"Error: Input file not found: {args.input_file}") print(f"Error: Input file not found: {args.input_file}")
sys.exit(1) sys.exit(1)
@@ -185,7 +142,12 @@ def main():
print(f"Error: Tracklist file not found: {args.tracklist_file}") print(f"Error: Tracklist file not found: {args.tracklist_file}")
sys.exit(1) sys.exit(1)
# Check FFmpeg # Ensure the replacement character is a single character.
if len(args.replacement_char) != 1:
print("Error: --replacement-char must be a single character.")
sys.exit(1)
# Check that FFmpeg is installed.
try: try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
@@ -193,14 +155,14 @@ def main():
print(" - https://ffmpeg.org/download.html") print(" - https://ffmpeg.org/download.html")
sys.exit(1) sys.exit(1)
# Parse tracklist format # Parse the tracklist using the userprovided format.
try: try:
tokens = parse_format(args.tracklist_format) tokens = parse_format(args.tracklist_format)
except ValueError as e: except ValueError as error:
print(f"Error in --tracklist-format: {e}") print(f"Error in --tracklist-format: {error}")
sys.exit(1) sys.exit(1)
# Read tracklist # Read and parse the tracklist file.
tracks = read_tracklist(args.tracklist_file, tokens) tracks = read_tracklist(args.tracklist_file, tokens)
if not tracks: if not tracks:
print("Error: No valid tracks found in tracklist file.") print("Error: No valid tracks found in tracklist file.")
@@ -208,6 +170,7 @@ def main():
print(f"Found {len(tracks)} tracks.") print(f"Found {len(tracks)} tracks.")
# Dryrun mode: display parsed data and exit.
if args.dry_run: if args.dry_run:
print("\nParsed tracklist:") print("\nParsed tracklist:")
print("-" * 60) print("-" * 60)
@@ -221,22 +184,34 @@ def main():
print(f"{idx:3d} | " + " | ".join(values)) print(f"{idx:3d} | " + " | ".join(values))
print("-" * 60) print("-" * 60)
print("Dryrun complete. No files were created.") print("Dryrun complete. No files were created.")
return sys.exit(0)
# Determine output directory # Determine the output directory.
if args.output_dir: if args.output_dir:
output_dir = args.output_dir output_dir = args.output_dir
print(f"Using custom output directory: {output_dir}")
else: else:
base_name = os.path.splitext(os.path.basename(args.input_file))[0] base_name = os.path.splitext(os.path.basename(args.input_file))[0]
output_dir = base_name + "_splits" output_dir = base_name + "_splits"
print(f"Using default output directory: {output_dir}")
# Run split # Run the splitter.
try: try:
split_audio(args.input_file, output_dir, tracks, args) split_audio(args.input_file, output_dir, tracks, args)
except Exception as e: except (RuntimeError, ValueError) as error:
print(f"Error during split: {e}") print(f"Error: {error}")
sys.exit(1) sys.exit(1)
# --------------------------------------------------------------------------
# Delete original file if requested and successful.
# --------------------------------------------------------------------------
if args.delete_original:
try:
os.remove(args.input_file)
print(f"Deleted original file: {args.input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
print("Done!") print("Done!")
+37
View File
@@ -0,0 +1,37 @@
services:
backend:
build:
context: .
dockerfile: web/backend/Dockerfile
volumes:
# Bind mount for source code (hot-reload for Python)
- ./web/backend:/app/backend
- ./audio_splitter:/app/audio_splitter
- ./setup.py:/app/setup.py
- ./pyproject.toml:/app/pyproject.toml
# Development data directory (overrides production)
- "./dev_data:/tmp/audio_splitter_web"
environment:
- DEBUG=1
- PYTHONUNBUFFERED=1
ports:
- "8000:8000"
frontend:
build:
context: ./web/frontend
# Use the same Dockerfile, but override CMD for development
dockerfile: Dockerfile
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
volumes:
# Bind mount for source code (hot-reload for Vite)
- ./web/frontend:/app
- node_modules:/app/node_modules
environment:
- BACKEND_URL=http://backend:8000
- VITE_BACKEND_URL=http://backend:8000
ports:
- "5173:5173"
volumes:
node_modules:
+5 -10
View File
@@ -1,24 +1,19 @@
services: services:
backend: backend:
build: image: git.vmn.su/max/audio_splitter_backend:latest
context: .
dockerfile: web/backend/Dockerfile
#image: max/audio_splitter_backend:latest
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
volumes: volumes:
#- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web" # Bind mount for persistent data storage.
- "/tmp/test:/tmp/audio_splitter_web" # Set BACKEND_DATA_DIR in .env to point to your data directory.
- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0} - DEBUG=${DEBUG:-0}
restart: unless-stopped restart: unless-stopped
frontend: frontend:
build: image: git.vmn.su/max/audio_splitter_frontend:latest
context: .
dockerfile: web/frontend/Dockerfile
#image: max/audio_splitter_frontend:latest
ports: ports:
- "${FRONTEND_PORT:-5173}:80" - "${FRONTEND_PORT:-5173}:80"
depends_on: depends_on:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
# Check if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# If /data is a directory, change its ownership to the container user
if [ -d "/data" ]; then
echo "Setting ownership of /data to appuser:appgroup"
chown -R appuser:appgroup /data
fi
# Drop privileges and run audio_splitter with the provided arguments
exec gosu appuser audio_splitter "$@"
else
# If not root, just run audio_splitter directly
# It's not supposed to be called
exec audio_splitter "$@"
fi
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env python3
"""
Generate TypeScript constants from audio_splitter/defaults.py.
"""
import importlib.util
import json
import sys
from pathlib import Path
# Get the directory where this script is located
SCRIPT_DIR = Path(__file__).parent.absolute()
# The defaults.py is in the same directory as the script
DEFAULTS_PATH = SCRIPT_DIR / "defaults.py"
# The frontend source is at SCRIPT_DIR.parent (i.e., /app)
# The generated file should be at /app/src/constants/generated.ts
FRONTEND_ROOT = SCRIPT_DIR.parent
OUTPUT_PATH = FRONTEND_ROOT / "src" / "constants" / "generated.ts"
def load_defaults_module():
spec = importlib.util.spec_from_file_location("defaults", DEFAULTS_PATH)
if spec is None:
raise RuntimeError(f"Could not load spec for {DEFAULTS_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def format_value(value):
"""Convert Python value to TypeScript literal."""
if value is None:
return "null"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, str):
# Use json.dumps to produce a properly escaped string literal
return json.dumps(value)
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
return f"[{', '.join(format_value(v) for v in value)}]"
return repr(value)
def main():
print(f"Generating TypeScript defaults from {DEFAULTS_PATH}")
defaults = load_defaults_module()
constants = {name: value for name, value in vars(defaults).items() if name.startswith("DEFAULT_")}
if not constants:
print("No DEFAULT_* constants found.")
sys.exit(1)
lines = [
"// ============================================================================",
"// GENERATED FILE DO NOT EDIT MANUALLY.",
"// This file is generated from audio_splitter/defaults.py.",
"// Run `npm run generate` or `python scripts/generate_ts_defaults.py` to update.",
"// ============================================================================",
"",
]
for name, value in sorted(constants.items()):
ts_value = format_value(value)
lines.append(f"export const {name} = {ts_value};")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text("\n".join(lines) + "\n")
print(f"✅ Generated {OUTPUT_PATH}")
print(f" {len(constants)} constants exported.")
if __name__ == "__main__":
main()
+2 -1
View File
@@ -1,3 +1,4 @@
"""API route handlers.""" """API route handlers."""
from . import upload, split, split_file, status, download, websocket, formats, info from . import upload, split, status, download
# websocket is imported directly in main.py to avoid circular import
+11 -5
View File
@@ -1,9 +1,8 @@
# web/backend/api/formats.py
"""Endpoint to expose format information to the frontend.""" """Endpoint to expose format information to the frontend."""
from fastapi import APIRouter from fastapi import APIRouter
from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO from backend.constants import FORMAT_INFO
router = APIRouter(prefix="/api", tags=["formats"]) router = APIRouter(prefix="/api", tags=["formats"])
@@ -11,9 +10,16 @@ router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats") @router.get("/formats")
async def get_formats(): async def get_formats():
""" """
Return the list of supported containers and codecs. Return the list of supported container formats with their properties.
""" """
return { return {
"containers": CONTAINER_INFO, "formats": [
"codecs": CODEC_INFO, {
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
} }
+2 -39
View File
@@ -5,12 +5,7 @@ from fastapi import APIRouter, HTTPException
from backend.config import settings from backend.config import settings
from backend.services.file_manager import FileManager from backend.services.file_manager import FileManager
from backend.services.task_manager import task_manager from backend.services.task_manager import task_manager
from backend.ffmpeg import get_stream_info
# Import core functions
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
from audio_splitter.formats import determine_default_format
from audio_splitter.constants import FORMAT_INFO
from audio_splitter.defaults import DEFAULT_FORMAT
router = APIRouter(prefix="/api", tags=["info"]) router = APIRouter(prefix="/api", tags=["info"])
@@ -18,8 +13,7 @@ router = APIRouter(prefix="/api", tags=["info"])
@router.get("/info/{task_id}") @router.get("/info/{task_id}")
async def get_task_info(task_id: str): async def get_task_info(task_id: str):
""" """
Return stream information (has_audio, has_video, has_subtitle, audio_codec) Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
for the uploaded file.
""" """
if not task_manager.has_task(task_id): if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found") raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
@@ -39,34 +33,3 @@ async def get_task_info(task_id: str):
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
@router.get("/info/recommended-format/{task_id}")
async def get_recommended_format(task_id: str):
"""
Return the recommended output format (container name) for the uploaded file,
based on its container and audio codec.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise HTTPException(status_code=404, detail="Input file not found")
try:
container = get_container_format(str(input_path))
codec = get_audio_codec(str(input_path))
fmt = determine_default_format(container, codec)
# Fallback if detection fails or format is unsupported
if fmt is None:
fmt = DEFAULT_FORMAT
if fmt not in FORMAT_INFO:
fmt = "mp3" # ultimate fallback
return {"format": fmt}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to determine format: {str(e)}")
+3 -31
View File
@@ -1,4 +1,4 @@
"""Split task endpoint (JSON tracklist).""" """Split task endpoint."""
from fastapi import APIRouter, HTTPException, BackgroundTasks from fastapi import APIRouter, HTTPException, BackgroundTasks
@@ -24,29 +24,6 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
detail=f"Task {task_id} is already {status['status']}" detail=f"Task {task_id} is already {status['status']}"
) )
# Build options dict from request
options = {
"container": request.container,
"audio_codec": request.audio_codec,
"video_codec": request.video_codec,
"subtitle_codec": request.subtitle_codec,
"video_quality": request.video_quality,
"drop_video": request.drop_video,
"drop_subs": request.drop_subs,
"number_tracks": request.number_tracks,
"replace_bad_chars": request.replace_bad_chars,
"replacement_char": request.replacement_char,
"bad_chars": request.bad_chars,
"skip_existing": request.skip_existing,
"output_template": request.output_template,
"album": request.album,
"comment": request.comment,
"no_comment": request.no_comment,
"comment_stream": request.comment_stream,
"merge_comments": request.merge_comments,
"comment_separator": request.comment_separator,
}
task_manager.update_task( task_manager.update_task(
task_id, task_id,
status=TaskStatus.PROCESSING, status=TaskStatus.PROCESSING,
@@ -54,14 +31,9 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
message="Preparing to split..." message="Preparing to split..."
) )
background_tasks.add_task( background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
run_split_task,
task_id,
request.tracklist,
options
)
return SplitResponse( return SplitResponse(
task_id=task_id, task_id=task_id,
status=TaskStatus.PROCESSING status=TaskStatus.PROCESSING
) )
-151
View File
@@ -1,151 +0,0 @@
"""Endpoint for splitting with a tracklist file upload."""
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.services.splitter import run_split_task
from backend.models.request import TracklistEntry
from backend.models.response import SplitResponse, TaskStatus
router = APIRouter(prefix="/api", tags=["split"])
from audio_splitter.tracklist import parse_format, read_tracklist
@router.post("/split-file", response_model=SplitResponse)
async def split_from_file(
background_tasks: BackgroundTasks,
task_id: str = Form(...),
tracklist_file: UploadFile = File(...),
# New fields
container: Optional[str] = Form(None),
audio_codec: str = Form("copy"),
video_codec: str = Form("copy"),
subtitle_codec: str = Form("copy"),
video_quality: Optional[int] = Form(None),
drop_video: bool = Form(False),
drop_subs: bool = Form(False),
number_tracks: bool = Form(False),
replace_bad_chars: bool = Form(False),
replacement_char: str = Form("_"),
bad_chars: str = Form(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
skip_existing: bool = Form(False),
output_template: str = Form("%an-%tn.%ext"),
album: Optional[str] = Form(None),
comment: Optional[str] = Form(None),
no_comment: bool = Form(False),
comment_stream: Optional[int] = Form(None),
merge_comments: bool = Form(False),
comment_separator: str = Form("; "),
options: str = Form("{}"), # backward-compatible, but we now use explicit fields
tracklist_format: str = Form("%ts %tn - %an"),
):
# Validate task exists
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException(
status_code=409,
detail=f"Task {task_id} is already {status['status']}"
)
if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException(
status_code=400,
detail="Tracklist file must be a text file (.txt or .text)"
)
try:
content = await tracklist_file.read()
text = content.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(
status_code=400,
detail="Tracklist file must be UTF-8 encoded"
)
if not text.strip():
raise HTTPException(
status_code=400,
detail="Tracklist file is empty"
)
# Parse tracklist using CLI logic
try:
tokens = parse_format(tracklist_format)
temp_tracklist_path = settings.temp_dir / task_id / "tracklist.txt"
temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True)
temp_tracklist_path.write_text(text, encoding='utf-8')
tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to parse tracklist: {str(e)}"
)
if not tracklist_dicts:
raise HTTPException(
status_code=400,
detail="No valid tracks found in tracklist file"
)
# Convert dicts to TracklistEntry objects
try:
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid tracklist data: {str(e)}"
)
# Build options dict from explicit fields (ignore `options` parameter)
options = {
"container": container,
"audio_codec": audio_codec,
"video_codec": video_codec,
"subtitle_codec": subtitle_codec,
"video_quality": video_quality,
"drop_video": drop_video,
"drop_subs": drop_subs,
"number_tracks": number_tracks,
"replace_bad_chars": replace_bad_chars,
"replacement_char": replacement_char,
"bad_chars": bad_chars,
"skip_existing": skip_existing,
"output_template": output_template,
"album": album,
"comment": comment,
"no_comment": no_comment,
"comment_stream": comment_stream,
"merge_comments": merge_comments,
"comment_separator": comment_separator,
}
# Update task status
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
background_tasks.add_task(
run_split_task,
task_id,
tracklist_entries,
options
)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+3 -4
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend.config import settings from backend.config import settings
from backend.api import upload, split, split_file, status, download, websocket, formats, info from backend.api import upload, split, status, download, websocket, formats, info
from backend.services import progress_publisher from backend.services import progress_publisher
app = FastAPI( app = FastAPI(
@@ -33,12 +33,11 @@ async def startup_event():
# Include routers # Include routers
app.include_router(upload.router) app.include_router(upload.router)
app.include_router(split.router) app.include_router(split.router)
app.include_router(split_file.router) # NEW
app.include_router(status.router) app.include_router(status.router)
app.include_router(download.router) app.include_router(download.router)
app.include_router(websocket.router) app.include_router(websocket.router)
app.include_router(formats.router) app.include_router(formats.router) # new
app.include_router(info.router) app.include_router(info.router) # new
@app.get("/") @app.get("/")
+5 -27
View File
@@ -17,16 +17,19 @@ class TracklistEntry(BaseModel):
@validator("ts") @validator("ts")
def validate_timestamp(cls, v: str) -> str: def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip() v = v.strip()
if not v: if not v:
raise ValueError("Timestamp cannot be empty") raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v: if "-" in v:
parts = v.split("-", 1) parts = v.split("-", 1)
start = parts[0].strip() start = parts[0].strip()
end = parts[1].strip() end = parts[1].strip()
if not start or not end: if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'") raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]: for ts in [start, end]:
cls._validate_single_timestamp(ts) cls._validate_single_timestamp(ts)
else: else:
@@ -36,6 +39,7 @@ class TracklistEntry(BaseModel):
@staticmethod @staticmethod
def _validate_single_timestamp(ts: str) -> None: def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":") parts = ts.split(":")
if len(parts) not in (2, 3): if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS") raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
@@ -51,30 +55,4 @@ class SplitRequest(BaseModel):
task_id: str = Field(..., description="Task ID from upload") task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks") tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
options: dict = Field(default_factory=dict, description="All CLI options")
# Container and codec options
container: Optional[str] = Field(None, description="Output container (auto-detect if None)")
audio_codec: Optional[str] = Field("copy", description="Audio codec (copy or encoder name)")
video_codec: Optional[str] = Field("copy", description="Video codec (copy or encoder name)")
subtitle_codec: Optional[str] = Field("copy", description="Subtitle codec (copy or encoder name)")
video_quality: Optional[int] = Field(None, description="Video quality (encoder-specific integer)")
# Stream handling
drop_video: bool = Field(False, description="Remove video streams")
drop_subs: bool = Field(False, description="Remove subtitle streams")
# Filename options
number_tracks: bool = Field(False, description="Prepend track numbers")
replace_bad_chars: bool = Field(False, description="Replace bad characters")
replacement_char: str = Field("_", description="Replacement character")
bad_chars: str = Field(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ', description="Bad characters to replace")
skip_existing: bool = Field(False, description="Skip existing files")
output_template: str = Field("%an-%tn.%ext", description="Output filename template")
# Metadata options
album: Optional[str] = Field(None, description="Album name")
comment: Optional[str] = Field(None, description="Comment")
no_comment: bool = Field(False, description="Ignore comment")
comment_stream: Optional[int] = Field(None, description="Comment stream index")
merge_comments: bool = Field(False, description="Merge all comments")
comment_separator: str = Field("; ", description="Separator for merged comments")
+1 -1
View File
@@ -1 +1 @@
"""Business logic services.""" """Business logic services."""
+7 -22
View File
@@ -40,13 +40,9 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from types import SimpleNamespace from types import SimpleNamespace
# Build args namespace using the new fields
args = SimpleNamespace( args = SimpleNamespace(
container=options.get("container"), # None -> auto-detect format=options.get("format", "mp3"),
audio_codec=options.get("audio_codec", "copy"), transcode_to=options.get("transcode_to", None),
video_codec=options.get("video_codec", "copy"),
subtitle_codec=options.get("subtitle_codec", "copy"),
video_quality=options.get("video_quality"),
drop_video=options.get("drop_video", False), drop_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False), drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False), number_tracks=options.get("number_tracks", False),
@@ -70,30 +66,17 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from audio_splitter.core import split_audio from audio_splitter.core import split_audio
# Detect attached picture and extract if applicable
cover_image_path = None
if not args.drop_video:
from audio_splitter.ffmpeg import is_attached_picture, extract_cover_image, get_stream_info
input_path_str = str(input_path)
stream_info = get_stream_info(input_path_str)
if stream_info.get('has_video') and is_attached_picture(input_path_str):
cover_image_path = output_dir / 'cover.png'
if extract_cover_image(input_path_str, str(cover_image_path)):
print(f"Extracted cover image for task {task_id}")
else:
cover_image_path = None
# Attach cover image to args
args.cover_image = str(cover_image_path) if cover_image_path else None
task_manager.update_task_with_progress( task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..." task_id, progress=10, message="Starting split..."
) )
# Run the split
split_audio(str(input_path), str(output_dir), tracks, args) split_audio(str(input_path), str(output_dir), tracks, args)
# Get output files
output_files = FileManager.get_output_files(task_id) output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress( task_manager.update_task_with_progress(
task_id, task_id,
progress=100, progress=100,
@@ -101,11 +84,13 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
status=TaskStatus.DONE status=TaskStatus.DONE
) )
# Add tracks to the task state
task_manager.update_task( task_manager.update_task(
task_id, task_id,
tracks=output_files tracks=output_files
) )
# Give WebSocket time to send the final message
time.sleep(0.5) time.sleep(0.5)
except Exception as e: except Exception as e:
+12 -27
View File
@@ -1,42 +1,27 @@
# web/frontend/Dockerfile # Stage 1: Build
# Build context must be the project root.
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
# Install Python for the generation script
RUN apk add --no-cache python3 py3-pip
WORKDIR /app WORKDIR /app
# Copy frontend package files and install dependencies # Install dependencies
COPY web/frontend/package.json web/frontend/package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
# Copy the frontend source code # Copy the application code and build
COPY web/frontend/ . COPY . .
# Copy the Python defaults and the generation script into the expected location
# The generate script in package.json expects ../../scripts/generate_ts_defaults.py
# So we must place it at /app/../../scripts/ which is /scripts/
# But we can't COPY to a parent directory. Instead, we'll copy to /app/scripts/
# and adjust the package.json script to use ./scripts/generate_ts_defaults.py
# Actually, the simplest fix is to copy to /app/scripts/ and then adjust package.json.
#
# Let's use a different approach: copy to /app/scripts/ and update the generate script.
COPY audio_splitter/defaults.py /app/scripts/defaults.py
COPY scripts/generate_ts_defaults.py /app/scripts/generate_ts_defaults.py
# Run the generation script
RUN python /app/scripts/generate_ts_defaults.py
# Build the frontend
RUN npm run build RUN npm run build
# Stage 2: Production (nginx) # Stage 2: Production (nginx)
FROM nginx:alpine FROM nginx:alpine
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY web/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port
EXPOSE 80 EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+1 -4
View File
@@ -4,9 +4,6 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate": "python scripts/generate_ts_defaults.py",
"predev": "npm run generate",
"prebuild": "npm run generate",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
@@ -36,4 +33,4 @@
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8" "vite": "^5.0.8"
} }
} }
+8 -7
View File
@@ -68,15 +68,16 @@ const App: React.FC = () => {
reset() reset()
} }
const isSplitDisabled = const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
!taskId ||
!isValid ||
entries.length === 0 ||
isProcessing ||
!!formatError
return ( return (
<ThemeProvider theme={createTheme({ palette: { mode: theme } })}> <ThemeProvider
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline /> <CssBaseline />
<Layout> <Layout>
<Grid container spacing={3}> <Grid container spacing={3}>
+10 -39
View File
@@ -1,12 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
TracklistEntry,
SplitOptions,
TaskStatus,
FormatsResponse,
UploadResponse,
SplitResponse,
} from '../types'
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -15,14 +8,16 @@ export const api = axios.create({
}, },
}) })
export const uploadFile = async (file: File): Promise<UploadResponse> => { export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await api.post('/upload', formData, { const response = await api.post('/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) })
return response.data return response.data
} }
@@ -30,32 +25,12 @@ export const startSplit = async (
task_id: string, task_id: string,
tracklist: TracklistEntry[], tracklist: TracklistEntry[],
options: SplitOptions options: SplitOptions
): Promise<SplitResponse> => { ): Promise<{ task_id: string; status: string }> => {
// Build request payload (only send fields that are not undefined) const response = await api.post('/split', {
const payload: any = {
task_id, task_id,
tracklist, tracklist,
container: options.container, // null means auto-detect options,
audio_codec: options.audio_codec, })
video_codec: options.video_codec,
subtitle_codec: options.subtitle_codec,
video_quality: options.video_quality,
drop_video: options.drop_video,
drop_subs: options.drop_subs,
number_tracks: options.number_tracks,
replace_bad_chars: options.replace_bad_chars,
replacement_char: options.replacement_char,
bad_chars: options.bad_chars,
skip_existing: options.skip_existing,
output_template: options.output_template,
album: options.album || null,
comment: options.comment || null,
no_comment: options.no_comment,
comment_stream: options.comment_stream,
merge_comments: options.merge_comments,
comment_separator: options.comment_separator,
}
const response = await api.post('/split', payload)
return response.data return response.data
} }
@@ -72,7 +47,8 @@ export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip` return `/api/download/${task_id}/splits.zip`
} }
export const getFormats = async (): Promise<FormatsResponse> => { // New functions for format validation feature
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
const response = await api.get('/formats') const response = await api.get('/formats')
return response.data return response.data
} }
@@ -87,8 +63,3 @@ export const getTaskInfo = async (task_id: string): Promise<{
const response = await api.get(`/info/${task_id}`) const response = await api.get(`/info/${task_id}`)
return response.data return response.data
} }
export const getRecommendedFormat = async (task_id: string): Promise<{ format: string }> => {
const response = await api.get(`/info/recommended-format/${task_id}`)
return response.data
}
+71 -134
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from 'react' import React, { useEffect } from 'react'
import { import {
Box, Box,
Paper, Paper,
@@ -16,14 +16,7 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore' import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore' import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client'
import { SplitOptions } from '../types'
import { useFilterCodecs } from '../hooks/useFilterCodecs'
import { useFormatValidation } from '../hooks/useFormatValidation'
// ------------------------------------------------------------------------------
// Section component (collapsible)
// ------------------------------------------------------------------------------
interface SectionProps { interface SectionProps {
title: string title: string
children: React.ReactNode children: React.ReactNode
@@ -32,6 +25,7 @@ interface SectionProps {
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => { const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded) const [expanded, setExpanded] = React.useState(defaultExpanded)
return ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box <Box
@@ -57,64 +51,54 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
) )
} }
// ------------------------------------------------------------------------------
// Main OptionsPanel
// ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => { export const OptionsPanel: React.FC = () => {
const { const { options, setOptions } = useOptionsStore()
options,
setOptions,
containers,
codecs,
compatibility,
setContainers,
setCodecs,
setCompatibility,
} = useOptionsStore()
const { hasVideo } = useUploadStore() const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore() const { formatError, setFormatError } = useValidationStore()
// Fetch formats on mount // Audio-only formats from backend constants (hardcoded for now)
useEffect(() => { const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
const fetchFormats = async () => {
try { const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const data = await getFormats() const newFormat = e.target.value
setContainers(data.containers || []) setOptions({ format: newFormat })
setCodecs(data.codecs || [])
setCompatibility(data.compatibility || {}) // Validate format
} catch (err) { if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
console.error('Failed to fetch formats:', err) setFormatError(
} `Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
} }
fetchFormats()
}, [setContainers, setCodecs, setCompatibility])
// --------------------------------------------------------------
// Filter codec options based on selected container
// --------------------------------------------------------------
const { filteredAudioCodecs, filteredVideoCodecs } = useFilterCodecs(compatibility, options.container, codecs)
// --------------------------------------------------------------
// Validate compatibility
// --------------------------------------------------------------
useFormatValidation(options, hasVideo, compatibility, containers, setFormatError)
// --------------------------------------------------------------
// Handlers
// --------------------------------------------------------------
const handleChange = (field: keyof SplitOptions, value: any) => {
setOptions({ [field]: value })
} }
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? null : e.target.value const checked = e.target.checked
handleChange('container', value) setOptions({ drop_video: checked })
// Re-validate format
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
setFormatError(
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
} }
// -------------------------------------------------------------- // Re-validate when hasVideo changes (e.g., after upload)
// Render useEffect(() => {
// -------------------------------------------------------------- const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
const newError = shouldShowError
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
: null
// Only update if the error state actually changes
if (newError !== formatError) {
setFormatError(newError)
}
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
return ( return (
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
@@ -127,101 +111,55 @@ export const OptionsPanel: React.FC = () => {
</Alert> </Alert>
)} )}
{/* ------------------------------------------------------------------------ {/* Output Settings */}
Output Settings
------------------------------------------------------------------------ */}
<Section title="Output Settings" defaultExpanded> <Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Container dropdown */}
<TextField <TextField
label="Container" label="Format"
select select
value={options.container ?? ''} value={options.format}
onChange={handleContainerChange} onChange={handleFormatChange}
fullWidth fullWidth
size="small" size="small"
error={!!formatError} error={!!formatError}
helperText={
formatError || "Select a container (auto-detect if empty)."
}
> >
<MenuItem value="">Auto-detect</MenuItem> <MenuItem value="mp3">MP3</MenuItem>
{containers.map((c) => ( <MenuItem value="m4a">M4A</MenuItem>
<MenuItem key={c.name} value={c.name}> <MenuItem value="mkv">MKV</MenuItem>
{c.name.toUpperCase()} ({c.extension}) <MenuItem value="mp4">MP4</MenuItem>
</MenuItem> <MenuItem value="ogg">OGG</MenuItem>
))} <MenuItem value="opus">OPUS</MenuItem>
<MenuItem value="flac">FLAC</MenuItem>
<MenuItem value="wav">WAV</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
</TextField> </TextField>
{/* Audio Codec dropdown */}
<TextField <TextField
label="Audio Codec" label="Transcode to"
select select
value={options.audio_codec} value={options.transcode_to || ''}
onChange={(e) => handleChange('audio_codec', e.target.value)} onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
fullWidth fullWidth
size="small" size="small"
helperText="Audio codec (copy = keep original)"
> >
<MenuItem value="copy">Copy (original)</MenuItem> <MenuItem value="">Copy (no transcoding)</MenuItem>
{filteredAudioCodecs.map((c) => ( <MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
<MenuItem key={c.name} value={c.name}> <MenuItem value="aac">AAC</MenuItem>
{c.name.toUpperCase()} <MenuItem value="libopus">OPUS</MenuItem>
</MenuItem>
))}
</TextField> </TextField>
{/* Video Codec dropdown */}
<TextField
label="Video Codec"
select
value={options.video_codec}
onChange={(e) => handleChange('video_codec', e.target.value)}
fullWidth
size="small"
helperText="Video codec (copy = keep original)"
disabled={!hasVideo || options.drop_video}
>
<MenuItem value="copy">Copy (original)</MenuItem>
{filteredVideoCodecs.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name.toUpperCase()}
</MenuItem>
))}
</TextField>
{/* Video Quality */}
<TextField
label="Video Quality"
type="number"
value={options.video_quality ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value, 10)
handleChange('video_quality', val)
}}
fullWidth
size="small"
disabled={!hasVideo || options.drop_video}
helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)"
InputProps={{
inputProps: { min: 0, max: 51, step: 1 },
}}
/>
{/* Drop video (if video present) */}
{hasVideo && ( {hasVideo && (
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={options.drop_video} checked={options.drop_video}
onChange={(e) => handleChange('drop_video', e.target.checked)} onChange={handleDropVideoChange}
/> />
} }
label="Drop video streams" label="Drop video streams"
/> />
)} )}
{/* Drop subtitles */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -234,9 +172,7 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* ------------------------------------------------------------------------ {/* Filename Settings */}
Filename Settings
------------------------------------------------------------------------ */}
<Section title="Filename Settings"> <Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -292,9 +228,7 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* ------------------------------------------------------------------------ {/* Metadata Settings */}
Metadata Settings
------------------------------------------------------------------------ */}
<Section title="Metadata Settings"> <Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -325,7 +259,7 @@ export const OptionsPanel: React.FC = () => {
type="number" type="number"
value={options.comment_stream ?? ''} value={options.comment_stream ?? ''}
onChange={(e) => onChange={(e) =>
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10)) handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
} }
size="small" size="small"
disabled={options.no_comment} disabled={options.no_comment}
@@ -350,9 +284,7 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* ------------------------------------------------------------------------ {/* Tracklist Settings */}
Tracklist Settings
------------------------------------------------------------------------ */}
<Section title="Tracklist Settings" defaultExpanded> <Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -367,4 +299,9 @@ export const OptionsPanel: React.FC = () => {
</Section> </Section>
</Paper> </Paper>
) )
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
} }
+12 -41
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material' import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore' import { useTracklistStore } from '../stores/tracklistStore'
@@ -10,9 +10,6 @@ export const TracklistEditor: React.FC = () => {
const { options } = useOptionsStore() const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const lineNumbersRef = useRef<HTMLDivElement>(null)
const validate = (text: string) => { const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format) const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries) setEntries(result.entries)
@@ -26,29 +23,10 @@ export const TracklistEditor: React.FC = () => {
validate(text) validate(text)
} }
const handleScroll = useCallback(() => {
if (lineNumbersRef.current && textAreaRef.current) {
lineNumbersRef.current.scrollTop = textAreaRef.current.scrollTop
}
}, [])
useEffect(() => {
const textArea = textAreaRef.current
if (textArea) {
textArea.addEventListener('scroll', handleScroll)
return () => textArea.removeEventListener('scroll', handleScroll)
}
}, [handleScroll])
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return if (acceptedFiles.length === 0) return
const file = acceptedFiles[0] const file = acceptedFiles[0]
const reader = new FileReader() const reader = new FileReader()
reader.onload = (event) => { reader.onload = (event) => {
@@ -70,6 +48,13 @@ export const TracklistEditor: React.FC = () => {
multiple: false, multiple: false,
}) })
// Re-validate when tracklist format changes
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
return ( return (
@@ -97,13 +82,12 @@ export const TracklistEditor: React.FC = () => {
</Typography> </Typography>
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2, position: 'relative' }}> <Box sx={{ display: 'flex', gap: 2 }}>
{/* Line numbers column */} {/* Line numbers column */}
<Box <Box
ref={lineNumbersRef}
sx={{ sx={{
minWidth: 40, minWidth: 40,
maxWidth: 60, // Allow more space for 3-digit numbers maxWidth: 40,
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
lineHeight: 1.7, lineHeight: 1.7,
@@ -111,11 +95,6 @@ export const TracklistEditor: React.FC = () => {
textAlign: 'right', textAlign: 'right',
userSelect: 'none', userSelect: 'none',
overflow: 'hidden', overflow: 'hidden',
paddingTop: '8.5px',
paddingBottom: '8.5px',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
whiteSpace: 'nowrap', // Prevent wrapping of line numbers
}} }}
> >
{rawText.split('\n').map((_, i) => ( {rawText.split('\n').map((_, i) => (
@@ -123,7 +102,7 @@ export const TracklistEditor: React.FC = () => {
))} ))}
</Box> </Box>
{/* Editor text area now with horizontal scroll and no wrap */} {/* Editor text area */}
<TextField <TextField
multiline multiline
fullWidth fullWidth
@@ -133,19 +112,11 @@ export const TracklistEditor: React.FC = () => {
onChange={handleTextChange} onChange={handleTextChange}
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`} placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
variant="outlined" variant="outlined"
inputRef={textAreaRef}
sx={{ sx={{
'& .MuiInputBase-root': { '& .MuiInputBase-root': {
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
lineHeight: 1.7, lineHeight: 1.7,
overflowX: 'auto', // Enable horizontal scroll
},
'& .MuiInputBase-input': {
paddingTop: '8.5px',
paddingBottom: '8.5px',
whiteSpace: 'nowrap', // Prevent wrapping
overflowX: 'auto',
}, },
}} }}
error={!isValid && errors.length > 0} error={!isValid && errors.length > 0}
+3 -15
View File
@@ -3,8 +3,7 @@ import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material' import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material' import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useOptionsStore } from '../stores/optionsStore' // NEW import { uploadFile, getTaskInfo } from '../api/client'
import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW
import { useTaskStore } from '../stores/taskStore' import { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3'] const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
@@ -31,7 +30,6 @@ export const UploadZone: React.FC = () => {
} = useUploadStore() } = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore() const { setTaskId: setTaskIdStore } = useTaskStore()
const { setOptions } = useOptionsStore() // NEW
const onDrop = useCallback( const onDrop = useCallback(
async (acceptedFiles: File[]) => { async (acceptedFiles: File[]) => {
@@ -67,19 +65,9 @@ export const UploadZone: React.FC = () => {
setHasAudio(info.has_audio) setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle) setHasSubtitle(info.has_subtitle)
setAudioCodec(info.audio_codec) setAudioCodec(info.audio_codec)
// Fetch recommended format and update options
try {
// Inside UploadZone.tsx, after fetching recommended format:
const rec = await getRecommendedFormat(taskId)
// Update options store with container (not format)
setOptions({ container: rec.format })
} catch (err) {
console.warn('Failed to fetch recommended format, using default', err)
}
} catch (err) { } catch (err) {
console.error('Failed to fetch stream info:', err) console.error('Failed to fetch stream info:', err)
// Don't block upload flow; user can manually change options // Don't block the upload flow if this fails; we'll just assume no video
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed') setError(err.response?.data?.detail || err.message || 'Upload failed')
@@ -90,7 +78,7 @@ export const UploadZone: React.FC = () => {
setFileSize(0) setFileSize(0)
} }
}, },
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore, setHasVideo, setHasAudio, setHasSubtitle, setAudioCodec, setOptions] [setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
) )
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
-31
View File
@@ -1,31 +0,0 @@
import { useMemo } from 'react'
import { CodecInfo } from '../types'
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export function useFilterCodecs(
compatibility: Record<string, CompatibilityEntry>,
container: string | null,
codecs: CodecInfo[]
) {
const filteredAudioCodecs = useMemo(() => {
const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.supports_transcoding)
const audioList = entry.audio
if (audioList === null) return codecs.filter(c => c.supports_transcoding)
return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name))
}, [compatibility, container, codecs])
const filteredVideoCodecs = useMemo(() => {
const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.supports_video)
const videoList = entry.video
if (videoList === null) return codecs.filter(c => c.supports_video)
return codecs.filter(c => c.supports_video && videoList.includes(c.name))
}, [compatibility, container, codecs])
return { filteredAudioCodecs, filteredVideoCodecs }
}
@@ -1,56 +0,0 @@
import { useEffect } from 'react'
import { ContainerInfo } from '../types'
import { CompatibilityEntry } from './useFilterCodecs'
export function useFormatValidation(
options: {
container: string | null
audio_codec: string
video_codec: string
drop_video: boolean
},
hasVideo: boolean,
compatibility: Record<string, CompatibilityEntry>,
containers: ContainerInfo[],
setFormatError: (error: string | null) => void
) {
useEffect(() => {
const selectedContainer = containers.find(c => c.name === options.container)
if (selectedContainer?.audio_only && hasVideo && !options.drop_video) {
setFormatError(
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.`
)
return
}
// Check audio codec compatibility
if (options.audio_codec && options.audio_codec !== 'copy' && options.container) {
const entry = compatibility?.[options.container]
if (entry) {
const audioList = entry.audio
if (audioList !== null && !audioList.includes(options.audio_codec)) {
setFormatError(
`Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
// Check video codec compatibility
if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) {
const entry = compatibility?.[options.container]
if (entry) {
const videoList = entry.video
if (videoList !== null && !videoList.includes(options.video_codec)) {
setFormatError(
`Video codec '${options.video_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
setFormatError(null)
}, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError])
}
+20 -60
View File
@@ -1,77 +1,37 @@
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions, ContainerInfo, CodecInfo } from '../types' import { SplitOptions } from '../types'
import {
DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_NO_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_MERGE_COMMENTS,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_TRACKLIST_FORMAT,
} from '../constants/generated'
// We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect)
const DEFAULT_OPTIONS: SplitOptions = { const DEFAULT_OPTIONS: SplitOptions = {
container: null, format: 'mp3',
audio_codec: 'copy', transcode_to: '',
video_codec: 'copy', drop_video: false,
subtitle_codec: 'copy', drop_subs: false,
video_quality: null, number_tracks: false,
drop_video: DEFAULT_DROP_VIDEO, replace_bad_chars: false,
drop_subs: DEFAULT_DROP_SUBS, replacement_char: '_',
number_tracks: DEFAULT_NUMBER_TRACKS, bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ',
replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS, skip_existing: false,
replacement_char: DEFAULT_REPLACEMENT_CHAR, output_template: '%an-%tn.%ext',
bad_chars: DEFAULT_BAD_CHARS, album: '',
skip_existing: DEFAULT_SKIP_EXISTING, comment: '',
output_template: DEFAULT_OUTPUT_TEMPLATE, no_comment: false,
album: DEFAULT_ALBUM ?? '', comment_stream: null,
comment: DEFAULT_COMMENT ?? '', merge_comments: false,
no_comment: DEFAULT_NO_COMMENT, comment_separator: '; ',
comment_stream: DEFAULT_COMMENT_STREAM, tracklist_format: '%ts %tn - %an', // NEW
merge_comments: DEFAULT_MERGE_COMMENTS,
comment_separator: DEFAULT_COMMENT_SEPARATOR,
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
} }
interface OptionsState { interface OptionsState {
options: SplitOptions options: SplitOptions
containers: ContainerInfo[] setOptions: (options: Partial<SplitOptions>) => void
codecs: CodecInfo[]
compatibility: Record<string, { audio: string[] | null; video: string[] | null }>
setOptions: (newOptions: Partial<SplitOptions>) => void
setContainers: (containers: ContainerInfo[]) => void
setCodecs: (codecs: CodecInfo[]) => void
setCompatibility: (compat: Record<string, { audio: string[] | null; video: string[] | null }>) => void
reset: () => void reset: () => void
} }
export const useOptionsStore = create<OptionsState>((set) => ({ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS }, options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
setOptions: (newOptions) => setOptions: (newOptions) =>
set((state) => ({ set((state) => ({
options: { ...state.options, ...newOptions }, options: { ...state.options, ...newOptions },
})), })),
setContainers: (containers) => set({ containers }), reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
setCodecs: (codecs) => set({ codecs }),
setCompatibility: (compatibility) => set({ compatibility }),
reset: () =>
set({
options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
}),
})) }))
+3 -35
View File
@@ -22,11 +22,8 @@ export interface TaskStatus {
} }
export interface SplitOptions { export interface SplitOptions {
container: string | null // null = auto-detect format: string
audio_codec: string // 'copy' or encoder name transcode_to?: string
video_codec: string // 'copy' or encoder name
subtitle_codec: string // 'copy' or encoder name
video_quality: number | null // encoder-specific integer
drop_video: boolean drop_video: boolean
drop_subs: boolean drop_subs: boolean
number_tracks: boolean number_tracks: boolean
@@ -41,36 +38,7 @@ export interface SplitOptions {
comment_stream: number | null comment_stream: number | null
merge_comments: boolean merge_comments: boolean
comment_separator: string comment_separator: string
tracklist_format: string // for frontend parsing tracklist_format: string
}
export interface ContainerInfo {
name: string
ffmpeg: string
extension: string
audio_only: boolean
supports_video: boolean
supports_subs: boolean
}
export interface CodecInfo {
name: string
ffmpeg: string
recommended_container: string
recommended_extension: string
supports_transcoding: boolean
supports_video: boolean
}
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export interface FormatsResponse {
containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, CompatibilityEntry>
} }
export interface UploadResponse { export interface UploadResponse {