Compare commits
13 Commits
7f2b82aae0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 539ce3a801 | |||
| 3abb66fbdf | |||
| 8fb68e76ab | |||
| 5e124645c0 | |||
| 02b730b082 | |||
| c27f832c95 | |||
| 5cf6812338 | |||
| bb883409e1 | |||
| f868de9d3b | |||
| 800ad9c280 | |||
| bf6c6780da | |||
| 96b1588fcc | |||
| f00a70cb2e |
@@ -39,3 +39,7 @@ test_data/
|
||||
*.wav
|
||||
*.txt
|
||||
*.log
|
||||
|
||||
|
||||
# Deployment
|
||||
deploy/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Build and Push Docker Image
|
||||
run-name: Production images are being built
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -60,4 +61,4 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger the webhook on the deployment server to start pulling the images
|
||||
run: curl "https://webhook.vmn.su/hooks/pull-audio-splitter"
|
||||
run: curl "https://webhook.vmn.su/hooks/deploy-audio-splitter?token=${{ secrets.CD_WEBHOOK_TOKEN }}"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
Full documentation – including all CLI options, tracklist formats, deployment guides, and architecture details – is available in the [**Wiki**](https://git.vmn.su/max/audio_splitter/wiki).
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Flexible tracklist parsing** – Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`)
|
||||
@@ -26,310 +30,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.8 or higher**
|
||||
- **FFmpeg** – required for audio processing
|
||||
|
||||
### Install FFmpeg
|
||||
|
||||
| OS | Command |
|
||||
| -------------------- | ------------------------------------------------------------ |
|
||||
| **Ubuntu/Debian** | `sudo apt install ffmpeg` |
|
||||
| **macOS (Homebrew)** | `brew install ffmpeg` |
|
||||
| **Windows** | Download from [ffmpeg.org](https://ffmpeg.org/download.html) |
|
||||
|
||||
### Install Audio Splitter
|
||||
|
||||
Clone the repository and install:
|
||||
|
||||
```bash
|
||||
git clone https://git.vmn.su/max/audio_splitter.git
|
||||
cd audio_splitter
|
||||
pip install .
|
||||
```
|
||||
|
||||
Now the `audio_splitter` command is available globally:
|
||||
|
||||
```bash
|
||||
audio_splitter input.mp3 tracks.txt
|
||||
```
|
||||
|
||||
> **Tip:** For development, install in editable mode: `pip install -e .`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Prepare a tracklist file
|
||||
|
||||
Create a `tracks.txt` file with one track per line:
|
||||
|
||||
```
|
||||
00:00 Intro
|
||||
01:30 Song One - Artist A
|
||||
04:20-06:45 Another Song - Artist B
|
||||
08:10 Finale - Artist C
|
||||
```
|
||||
|
||||
- `00:00` – start‑only timestamp (track ends at next track's start or end of file)
|
||||
- `04:20-06:45` – explicit start and end timestamps
|
||||
|
||||
### 2. Run the splitter
|
||||
|
||||
```bash
|
||||
audio_splitter my_album.mp3 tracks.txt
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Found 4 tracks.
|
||||
Detected streams: audio=True, video=False, subs=False
|
||||
Audio codec: mp3
|
||||
Output container: mp3
|
||||
Extracting track 1: Intro (00:00:00 - 00:01:30)
|
||||
-> Saved to: my_album_splits/Intro.mp3
|
||||
Extracting track 2: Song One (00:01:30 - 00:04:20)
|
||||
-> Saved to: my_album_splits/Song One - Artist A.mp3
|
||||
...
|
||||
Done!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Command‑Line Options
|
||||
|
||||
### Basic Options
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | ------------------------------------------------------------- |
|
||||
| `input_file` | Input audio/video file |
|
||||
| `tracklist_file` | Tracklist file |
|
||||
| `-o, --output-dir DIR` | Output directory (default: `<input>_splits`) |
|
||||
| `--format FORMAT` | Output container format (mp3, m4a, mkv, mp4, ogg, opus, etc.) |
|
||||
| `--transcode-to CODEC` | Re‑encode audio to CODEC (e.g., libmp3lame, aac, libopus) |
|
||||
| `--drop-video` | Remove video streams |
|
||||
| `--drop-subs` | Remove subtitle streams |
|
||||
| `--dry-run` | Preview parsed tracklist without splitting |
|
||||
|
||||
### Filename Options
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------------- | --------------------------------------------------------------- |
|
||||
| `--number-tracks` | Prepend track number (`01 - `) to filenames |
|
||||
| `--output-template TEMPLATE` | Custom filename template (default: `%an-%tn.%ext`) |
|
||||
| `--replace-bad-chars` | Replace problematic characters in filenames |
|
||||
| `--replacement-char CHAR` | Replacement character (default: `_`) |
|
||||
| `--bad-chars CHARS` | Characters to replace (default includes space and single quote) |
|
||||
|
||||
### Metadata Options
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `--album ALBUM` | Set album name (overrides parsed `%al`) |
|
||||
| `--comment COMMENT` | Set comment text |
|
||||
| `--no-comment` | Ignore comment entirely |
|
||||
| `--comment-stream INDEX` | Select comment from a specific stream (0‑based) |
|
||||
| `--merge-comments` | Merge all comments from all streams |
|
||||
| `--comment-separator SEP` | Separator for merged comments (default: `; `) |
|
||||
|
||||
### Tracklist Format Options
|
||||
|
||||
| Option | Description |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| `--tracklist-format FORMAT` | Custom tracklist format (default: `%ts %tn - %an`) |
|
||||
| `--skip-existing` | Skip extraction if output file already exists |
|
||||
|
||||
### Other Options
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | --------------------------------------------------------- |
|
||||
| `--delete-original` | Delete the original input file after successful splitting |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Placeholders Reference
|
||||
|
||||
### Tracklist Format Placeholders (`--tracklist-format`)
|
||||
|
||||
| Placeholder | Meaning |
|
||||
| ----------- | --------------------------------------------------- |
|
||||
| `%ts` | **Timestamp** – required (`00:00` or `00:00-01:30`) |
|
||||
| `%tn` | Track name |
|
||||
| `%an` | Author/artist |
|
||||
| `%al` | Album |
|
||||
| `%date` | Date/year |
|
||||
| `%ext` | File extension |
|
||||
|
||||
**Default:** `%ts %tn - %an`
|
||||
|
||||
### Output Template Placeholders (`--output-template`)
|
||||
|
||||
| Placeholder | Meaning |
|
||||
| ----------- | -------------------------------------- |
|
||||
| `%tn` | Track name |
|
||||
| `%an` | Author/artist |
|
||||
| `%al` | Album |
|
||||
| `%date` | Date/year |
|
||||
| `%ext` | File extension (without leading dot) |
|
||||
| `%num` | Track number (zero‑padded, e.g., `01`) |
|
||||
|
||||
**Default:** `%an-%tn.%ext`
|
||||
|
||||
---
|
||||
|
||||
## 💡 Examples
|
||||
|
||||
### Custom tracklist format
|
||||
|
||||
If your tracklist uses `artist - title [time]`:
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--tracklist-format "%an - %tn [%ts]"
|
||||
```
|
||||
|
||||
### Custom output filenames
|
||||
|
||||
Name files as `01 - Artist - Song.mp3`:
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--output-template "%num - %an - %tn.%ext"
|
||||
```
|
||||
|
||||
### Override album and comment
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--album "Greatest Hits" \
|
||||
--comment "Live recording"
|
||||
```
|
||||
|
||||
### Merge multiple comments from input file
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--merge-comments \
|
||||
--comment-separator " | "
|
||||
```
|
||||
|
||||
### Delete original file after splitting
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt --delete-original
|
||||
```
|
||||
|
||||
### Dry‑run to preview parsing
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt --dry-run
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Parsed tracklist:
|
||||
------------------------------------------------------------
|
||||
ts | tn | an
|
||||
------------------------------------------------------------
|
||||
1 | 00:00 | Intro |
|
||||
2 | 01:30 | Song One | Artist A
|
||||
3 | 04:20-06:45 | Another Song | Artist B
|
||||
...
|
||||
------------------------------------------------------------
|
||||
Dry‑run complete. No files were created.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
You can run `audio_splitter` in a Docker container without installing Python or FFmpeg on your host.
|
||||
|
||||
### Pull the Image (Optional)
|
||||
|
||||
```bash
|
||||
docker pull yourusername/audio_splitter:latest
|
||||
```
|
||||
|
||||
### Build the Image Locally
|
||||
|
||||
```bash
|
||||
docker build -t audio_splitter .
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
**You must mount your working directory to `/data` inside the container.**
|
||||
The container will automatically adjust permissions so that you can read input files and write output files.
|
||||
|
||||
Simply provide the arguments as you would to the `audio_splitter` command:
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt [OPTIONS]
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
**Basic split:**
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt
|
||||
```
|
||||
|
||||
**With custom options:**
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --album "Greatest Hits" --format mp3 --number-tracks
|
||||
```
|
||||
|
||||
**Dry‑run:**
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --dry-run
|
||||
```
|
||||
|
||||
**Help:**
|
||||
|
||||
```bash
|
||||
docker run --rm audio_splitter --help
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
All output files are written to the mounted directory on your host (under the default `input_splits/` subdirectory, or any custom `--output-dir` you specify).
|
||||
|
||||
### Permission Handling
|
||||
|
||||
The container automatically adjusts ownership of the mounted `/data` directory so that the container user can read and write files there. No `--user` or `:z` flags are required.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
audio_splitter/
|
||||
├── __init__.py # Package initialisation
|
||||
├── constants.py # Global constants (FORMAT_INFO, DEFAULT_BAD_CHARS)
|
||||
├── utils.py # Generic helpers (timestamps, string manipulation)
|
||||
├── tracklist.py # Tracklist parsing with custom formats
|
||||
├── ffmpeg.py # FFmpeg/FFprobe interactions and command building
|
||||
├── timestamp.py # Timestamp parsing and resolution
|
||||
├── filename.py # Output filename generation
|
||||
├── metadata.py # Metadata selection and building
|
||||
├── formats.py # Container format decision and validation
|
||||
├── core.py # Main orchestration logic
|
||||
├── main.py # Command‑line interface
|
||||
├── docker-entrypoint.sh # Docker entrypoint script
|
||||
├── Dockerfile # Docker image definition
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ def split_audio(input_file, output_directory, tracks, args):
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Format decision
|
||||
# --------------------------------------------------------------------------
|
||||
output_format = determine_output_format(stream_info, args.format, args.transcode_to)
|
||||
output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file)
|
||||
print(f"Output container: {output_format}")
|
||||
|
||||
validate_format_compatibility(output_format, stream_info,
|
||||
|
||||
+106
-70
@@ -1,7 +1,8 @@
|
||||
"""FFmpeg / FFprobe interactions and command building."""
|
||||
"""FFmpeg/FFprobe interaction utilities for the CLI and web backend."""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .constants import FORMAT_INFO
|
||||
from .utils import format_time
|
||||
@@ -24,7 +25,10 @@ def get_audio_duration(input_file: str) -> float:
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
try:
|
||||
return float(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def has_stream_type(input_file: str, stream_type: str) -> bool:
|
||||
@@ -49,7 +53,7 @@ def has_stream_type(input_file: str, stream_type: str) -> bool:
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_audio_codec(input_file: str) -> str:
|
||||
def get_audio_codec(input_file: str) -> Optional[str]:
|
||||
"""
|
||||
Return the codec name of the first audio stream.
|
||||
|
||||
@@ -71,7 +75,7 @@ def get_audio_codec(input_file: str) -> str:
|
||||
return codec if codec else None
|
||||
|
||||
|
||||
def get_stream_info(input_file: str):
|
||||
def get_stream_info(input_file: str) -> Dict[str, any]:
|
||||
"""
|
||||
Collect information about the streams present in the input file.
|
||||
|
||||
@@ -88,9 +92,97 @@ def get_stream_info(input_file: str):
|
||||
'audio_codec': get_audio_codec(input_file)
|
||||
}
|
||||
|
||||
def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_path,
|
||||
stream_info, format_opt, transcode_audio,
|
||||
drop_video, drop_subs, metadata=None):
|
||||
|
||||
def get_metadata(input_file: str) -> Dict[str, any]:
|
||||
"""
|
||||
Retrieve metadata from the input file using ffprobe with JSON output.
|
||||
|
||||
Returns a dict with keys: album, title, comments (list of (stream_index, comment)).
|
||||
"""
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_entries', 'format_tags:stream_tags',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {'album': None, 'title': None, 'comments': []}
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
album = None
|
||||
title = None
|
||||
comments = []
|
||||
|
||||
fmt_tags = data.get('format', {}).get('tags', {})
|
||||
album = fmt_tags.get('album') or album
|
||||
title = fmt_tags.get('title') or title
|
||||
|
||||
for idx, stream in enumerate(data.get('streams', [])):
|
||||
stream_tags = stream.get('tags', {})
|
||||
if 'album' in stream_tags:
|
||||
album = stream_tags['album']
|
||||
if 'title' in stream_tags:
|
||||
title = stream_tags['title']
|
||||
if 'comment' in stream_tags:
|
||||
comments.append((idx, stream_tags['comment']))
|
||||
|
||||
return {'album': album, 'title': title, 'comments': comments}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return {'album': None, 'title': None, 'comments': []}
|
||||
|
||||
|
||||
def get_container_format(input_file: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve the container format name (e.g., 'mp4', 'mp3', 'matroska') from the input file.
|
||||
|
||||
Args:
|
||||
input_file: Path to the media file.
|
||||
|
||||
Returns:
|
||||
Container format name (normalized) or None if detection fails.
|
||||
"""
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=format_name',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
format_name = result.stdout.strip().split(',')[0] # take first if multiple
|
||||
if not format_name:
|
||||
return None
|
||||
# Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific)
|
||||
# This mapping is purely for container identification.
|
||||
mapping = {
|
||||
'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container
|
||||
'mp2': 'mp3',
|
||||
'mp4': 'mp4',
|
||||
'm4a': 'mp4', # M4A is MP4 container
|
||||
'mov': 'mp4', # QuickTime is MP4-like
|
||||
'3gp': 'mp4',
|
||||
'matroska': 'matroska',
|
||||
'webm': 'matroska', # WebM uses Matroska container
|
||||
'ogg': 'ogg',
|
||||
'flac': 'flac',
|
||||
'wav': 'wav',
|
||||
'aac': 'aac',
|
||||
'opus': 'opus',
|
||||
'mp3': 'mp3',
|
||||
'adts': 'aac', # raw AAC in ADTS container
|
||||
'amr': 'amr', # AMR container (rare)
|
||||
}
|
||||
return mapping.get(format_name, format_name)
|
||||
|
||||
|
||||
def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int,
|
||||
output_path: str, stream_info: Dict, format_opt: Optional[str],
|
||||
transcode_audio: Optional[str], drop_video: bool, drop_subs: bool,
|
||||
metadata: Optional[Dict] = None) -> List[str]:
|
||||
"""
|
||||
Construct the FFmpeg command line as a list of arguments.
|
||||
|
||||
@@ -116,18 +208,17 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
|
||||
'-t', format_time(duration_seconds)
|
||||
]
|
||||
|
||||
# -------------------- Clear all original metadata --------------------
|
||||
# Clear all original metadata.
|
||||
cmd.append('-map_metadata')
|
||||
cmd.append('-1')
|
||||
|
||||
# -------------------- Apply custom metadata --------------------
|
||||
# Apply custom metadata.
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
if value is not None and value != '':
|
||||
cmd.extend(['-metadata', f"{key}={value}"])
|
||||
|
||||
# -------------------- Stream mapping --------------------
|
||||
# Map the streams we want to keep.
|
||||
# Stream mapping.
|
||||
if drop_video and drop_subs:
|
||||
cmd.extend(['-map', '0:a:0'])
|
||||
elif drop_video:
|
||||
@@ -137,7 +228,7 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
|
||||
else:
|
||||
cmd.extend(['-map', '0'])
|
||||
|
||||
# -------------------- Audio codec --------------------
|
||||
# Audio codec.
|
||||
if transcode_audio:
|
||||
cmd.extend(['-c:a', transcode_audio])
|
||||
if transcode_audio in ('libmp3lame', 'mp3'):
|
||||
@@ -147,77 +238,22 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
|
||||
else:
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
|
||||
# -------------------- Video codec --------------------
|
||||
# Video codec.
|
||||
if not drop_video and stream_info['has_video']:
|
||||
cmd.extend(['-c:v', 'copy'])
|
||||
else:
|
||||
cmd.append('-vn')
|
||||
|
||||
# -------------------- Subtitle codec --------------------
|
||||
# Subtitle codec.
|
||||
if not drop_subs and stream_info['has_subtitle']:
|
||||
cmd.extend(['-c:s', 'copy'])
|
||||
else:
|
||||
cmd.append('-sn')
|
||||
|
||||
# -------------------- Output format --------------------
|
||||
# Output format.
|
||||
if format_opt:
|
||||
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
|
||||
cmd.extend(['-f', ffmpeg_format])
|
||||
|
||||
# Overwrite output if it already exists.
|
||||
cmd.extend(['-y', output_path])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_metadata(input_file: str) -> dict:
|
||||
"""
|
||||
Retrieve metadata from the input file using ffprobe with JSON output.
|
||||
Returns a dict with:
|
||||
- album: merged from all sources (last wins)
|
||||
- title: merged from all sources (last wins)
|
||||
- comments: list of (stream_index, comment) tuples
|
||||
"""
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_entries', 'format_tags:stream_tags',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {'album': None, 'title': None, 'comments': []}
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
# Collect album and title (merged, last wins).
|
||||
album = None
|
||||
title = None
|
||||
comments = [] # list of (stream_index, comment)
|
||||
|
||||
# Format tags.
|
||||
fmt_tags = data.get('format', {}).get('tags', {})
|
||||
album = fmt_tags.get('album') or album
|
||||
title = fmt_tags.get('title') or title
|
||||
# Format does not have a stream index; we'll treat it as -1 if needed.
|
||||
|
||||
# Stream tags.
|
||||
for idx, stream in enumerate(data.get('streams', [])):
|
||||
stream_tags = stream.get('tags', {})
|
||||
# Album and title: update if present.
|
||||
if 'album' in stream_tags:
|
||||
album = stream_tags['album']
|
||||
if 'title' in stream_tags:
|
||||
title = stream_tags['title']
|
||||
# Comment: collect all occurrences.
|
||||
if 'comment' in stream_tags:
|
||||
comments.append((idx, stream_tags['comment']))
|
||||
|
||||
return {
|
||||
'album': album,
|
||||
'title': title,
|
||||
'comments': comments
|
||||
}
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return {'album': None, 'title': None, 'comments': []}
|
||||
|
||||
|
||||
@@ -1,16 +1,79 @@
|
||||
"""Container format decision and validation."""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .constants import FORMAT_INFO
|
||||
|
||||
|
||||
def determine_output_format(stream_info, user_format, transcode_audio):
|
||||
def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Given the container format and audio codec, determine the recommended output format.
|
||||
|
||||
This is used when the user has not explicitly specified a format.
|
||||
It prioritizes the codec to choose the most appropriate container/extension.
|
||||
|
||||
Args:
|
||||
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
|
||||
codec: Audio codec name (e.g., 'opus', 'aac', 'mp3') as returned by get_audio_codec().
|
||||
|
||||
Returns:
|
||||
Format name (e.g., 'opus', 'm4a', 'mp3') or None if unknown.
|
||||
"""
|
||||
if not container:
|
||||
return None
|
||||
|
||||
# Codec-based decisions (highest priority)
|
||||
if codec == 'opus':
|
||||
return 'opus'
|
||||
if codec in ('aac', 'alac', 'he-aac'):
|
||||
return 'm4a'
|
||||
if codec == 'mp3':
|
||||
return 'mp3'
|
||||
if codec == 'vorbis':
|
||||
return 'ogg'
|
||||
if codec == 'flac':
|
||||
return 'flac'
|
||||
|
||||
# Container-based fallback (lower priority)
|
||||
if container in ('mp4', 'm4a', 'mov', '3gp'):
|
||||
return 'mp4'
|
||||
if container in ('matroska', 'webm'):
|
||||
return 'matroska'
|
||||
if container in ('ogg',):
|
||||
return 'ogg'
|
||||
if container in ('mp3', 'mpeg'):
|
||||
return 'mp3'
|
||||
if container == 'flac':
|
||||
return 'flac'
|
||||
if container == 'wav':
|
||||
return 'wav'
|
||||
if container == 'aac':
|
||||
return 'aac'
|
||||
if container == 'opus':
|
||||
return 'opus'
|
||||
if container == 'amr':
|
||||
return 'amr'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def determine_output_format(stream_info: Dict, user_format: Optional[str],
|
||||
transcode_audio: Optional[str], input_file: Optional[str] = None) -> str:
|
||||
"""
|
||||
Decide which container format to use.
|
||||
|
||||
If user_format is provided, use it.
|
||||
Else, try to detect the input file's container and codec, and use the recommended format.
|
||||
If detection fails or format is not supported, fallback to:
|
||||
- MKV if video/subtitles exist
|
||||
- MP3 if the audio codec is MP3
|
||||
- MP4 (M4A) otherwise
|
||||
|
||||
Args:
|
||||
stream_info: Dict from get_stream_info().
|
||||
user_format: User‑requested format (or None).
|
||||
transcode_audio: Audio codec to transcode to (or None).
|
||||
transcode_audio: Audio codec to transcode to (or None) (unused in this function).
|
||||
input_file: Path to the input file (optional, used to detect container and codec).
|
||||
|
||||
Returns:
|
||||
A format name that exists in FORMAT_INFO.
|
||||
@@ -18,11 +81,22 @@ def determine_output_format(stream_info, user_format, transcode_audio):
|
||||
if user_format:
|
||||
return user_format
|
||||
|
||||
# If video or subtitles exist, use MKV (which supports everything).
|
||||
if stream_info['has_video'] or stream_info['has_subtitle']:
|
||||
return 'matroska'
|
||||
# If input_file is provided, try to detect container and codec
|
||||
if input_file:
|
||||
try:
|
||||
from .ffmpeg import get_container_format, get_audio_codec
|
||||
container = get_container_format(input_file)
|
||||
codec = get_audio_codec(input_file)
|
||||
fmt = determine_default_format(container, codec)
|
||||
if fmt in FORMAT_INFO:
|
||||
return fmt
|
||||
except Exception:
|
||||
# If detection fails, fall through to legacy logic
|
||||
pass
|
||||
|
||||
# Audio‑only: choose based on the current audio codec.
|
||||
# Fallback: legacy behavior
|
||||
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
|
||||
return 'matroska'
|
||||
audio_codec = stream_info.get('audio_codec', '')
|
||||
if audio_codec == 'mp3':
|
||||
return 'mp3'
|
||||
@@ -30,7 +104,8 @@ def determine_output_format(stream_info, user_format, transcode_audio):
|
||||
return 'mp4' # .m4a
|
||||
|
||||
|
||||
def validate_format_compatibility(format_name, stream_info, drop_video, drop_subs):
|
||||
def validate_format_compatibility(format_name: str, stream_info: Dict,
|
||||
drop_video: bool, drop_subs: bool) -> None:
|
||||
"""
|
||||
Ensure the chosen container can accommodate the streams we intend to keep.
|
||||
|
||||
@@ -43,12 +118,12 @@ def validate_format_compatibility(format_name, stream_info, drop_video, drop_sub
|
||||
return
|
||||
|
||||
if info['audio_only']:
|
||||
if stream_info['has_video'] and not drop_video:
|
||||
if stream_info.get('has_video') and not drop_video:
|
||||
raise ValueError(
|
||||
f"Format '{format_name}' does not support video streams. "
|
||||
"Please use --drop-video or choose a container that supports video."
|
||||
)
|
||||
if stream_info['has_subtitle'] and not drop_subs:
|
||||
if stream_info.get('has_subtitle') and not drop_subs:
|
||||
raise ValueError(
|
||||
f"Format '{format_name}' does not support subtitle streams. "
|
||||
"Please use --drop-subs or choose a container that supports subtitles."
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# HTTPS Server Block (TLS Termination)
|
||||
# ------------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your.domain.tld;
|
||||
ssl_certificate path;
|
||||
ssl_certificate_key path;
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Security Headers
|
||||
# --------------------------------------------------------------------------
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Proxy to Frontend Container (everything goes through it)
|
||||
# --------------------------------------------------------------------------
|
||||
location / {
|
||||
# Forward all traffic to the frontend container
|
||||
proxy_pass http://127.0.0.1:5173; # <-- Frontend host port (adjust if needed)
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Headers for correct client IP and protocol forwarding
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# The frontend container handles WebSocket upgrades internally,
|
||||
# but we still need to pass the upgrade headers through.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Increase timeouts for long-running operations
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
|
||||
# Allow large file uploads (matches client_max_body_size in frontend NGINX)
|
||||
client_max_body_size 500M;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user