24 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
max 5e124645c0 Merge pull request 'feat(core): output format detected logic improved' (#4) from reverse-proxy into main
Reviewed-on: #4
2026-08-19 10:21:11 +04:00
max 02b730b082 CHANGE: maps codec/container/file_extension more accurate
Build and Push Docker Image / build-and-push-backend (pull_request) Failing after 19m21s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 54s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 11s
2026-08-19 11:12:48 +05:00
max c27f832c95 Update README.md 2026-08-07 06:35:53 +00:00
max 5cf6812338 Update README.md 2026-08-07 06:34:36 +00:00
max bb883409e1 run-name added for images building action 2026-08-05 17:11:43 +05:00
max f868de9d3b Merge pull request 'Reverse proxy config added, secret is used in build images action' (#3) from reverse-proxy into main
Reviewed-on: #3
2026-08-05 12:08:10 +00:00
max 800ad9c280 Building images action uses a secret to authenticate webhook server
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 1m2s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 49s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-08-05 16:57:12 +05:00
max bf6c6780da New deployment instructions added - simple NGINX reverse proxy example 2026-08-05 14:33:03 +05:00
max 96b1588fcc Merge pull request 'Ci cd merge request' (#2) from ci-cd into main
Reviewed-on: #2
2026-08-05 04:57:46 +00:00
max 7f2b82aae0 .env.example is improved, comments added. docker-compose-prod.yaml is replaced with docker-compose.yaml, development version is docker-compose.override.yaml now
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 21m4s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 52s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-08-05 09:52:23 +05:00
max a028b875fb building image actions is triggered only if a pull request has been merged into main 2026-08-05 09:07:00 +05:00
max d1a166a0f1 docker-compose-prod.yaml uses more variables from .env.example now 2026-08-05 08:52:45 +05:00
max fb9413e6f8 .env is untracked now 2026-08-04 21:50:06 +05:00
max e0490c5f41 .env.example is committed to the repo from now on. .env is used as a development file and ignored 2026-08-04 21:46:48 +05:00
max b6deed78c8 Building image action builds both images first, only then notifies the deployment server. Docker compose production file added 2026-08-04 20:41:21 +05:00
max e802b684ff Docker build context fixed in frontend building job
Build and Push Docker Image / build-and-push (push) Successful in 4m46s
2026-08-04 20:18:11 +05:00
max e28bcc27fc fixes in the build image action
Build and Push Docker Image / build-and-push (push) Failing after 1m27s
2026-08-04 20:12:41 +05:00
max 3a326859de Building image action fixed to use correct Dockerfile and build context
Build and Push Docker Image / build-and-push (push) Failing after 19m37s
2026-08-04 19:50:41 +05:00
max 26fb4fb3f4 Test CI/CD job was removed. Image building job was updated to build web version of the app
Build and Push Docker Image / build-and-push (push) Failing after 39s
2026-08-04 19:17:36 +05:00
max c455bd541b New test action added. It builds a docker image and pushes it to the registry
Build and Push Docker Image / build-and-push (push) Successful in 20m15s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 15s
2026-08-04 10:56:01 +05:00
max c66dbf9dc3 Test CI/CD file added
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 18s
2026-08-03 13:02:48 +05:00
15 changed files with 795 additions and 408 deletions
+4
View File
@@ -39,3 +39,7 @@ test_data/
*.wav *.wav
*.txt *.txt
*.log *.log
# Deployment
deploy/
-8
View File
@@ -1,8 +0,0 @@
# Backend
DEBUG=0
MAX_UPLOAD_SIZE_MB=500
TEMP_DIR=/tmp/audio_splitter_web
CLEANUP_AFTER_SECONDS=3600
# Frontend (development)
VITE_BACKEND_URL=http://localhost:8000
+26
View File
@@ -0,0 +1,26 @@
# ------------------------------------------------------------------------------
# Production Environment Variables
# ------------------------------------------------------------------------------
# Backend
DEBUG=0
MAX_UPLOAD_SIZE_MB=500
CLEANUP_AFTER_SECONDS=3600
# Backend data directory (bind mount)
# This directory will store all temporary files during splitting.
# It can get large (audio files), so place it on a partition with enough space.
BACKEND_DATA_DIR=/var/lib/audio_splitter_data
# Ports
BACKEND_PORT=8000
FRONTEND_PORT=5173
# ------------------------------------------------------------------------------
# Development Overrides (docker-compose.override.yaml)
# ------------------------------------------------------------------------------
# These are only used when docker-compose.override.yaml is present.
# They override the production settings for local development.
# Frontend (development)
VITE_BACKEND_URL=http://localhost:8000
+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
+64
View File
@@ -0,0 +1,64 @@
name: Build and Push Docker Image
run-name: Production images are being built
on:
pull_request:
types: [closed]
branches: [ main ]
env:
REGISTRY: git.vmn.su
OWNER: max
REPO: audio_splitter
jobs:
build-and-push-backend:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: |
echo "${{ secrets.CI_PUSHER_GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Build backend image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }} -f ./web/backend/Dockerfile .
docker tag ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:latest
- name: Push backend image
run: |
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:latest
build-and-push-frontend:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: |
echo "${{ secrets.CI_PUSHER_GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Build frontend image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }} -f ./web/frontend/Dockerfile ./web/frontend
docker tag ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:latest
- name: Push frontend image
run: |
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:latest
notify-deployment-server:
if: github.event.pull_request.merged == true
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/deploy-audio-splitter?token=${{ secrets.CD_WEBHOOK_TOKEN }}"
+1
View File
@@ -7,3 +7,4 @@ test_data/
venv/ venv/
web/frontend/node_modules web/frontend/node_modules
TODO.md TODO.md
.env
+4 -303
View File
@@ -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 ## ✨ Features
- **Flexible tracklist parsing** Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`) - **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` startonly 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!
```
---
## ⚙️ CommandLine 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` | Reencode 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 (0based) |
| `--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 (zeropadded, 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
```
### Dryrun 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
...
------------------------------------------------------------
Dryrun 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
```
**Dryrun:**
```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 # Commandline interface
├── docker-entrypoint.sh # Docker entrypoint script
├── Dockerfile # Docker image definition
└── README.md # This file
```
---
## 🤝 Contributing ## 🤝 Contributing
+1 -1
View File
@@ -44,7 +44,7 @@ def split_audio(input_file, output_directory, tracks, args):
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 2. Format decision # 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}") print(f"Output container: {output_format}")
validate_format_compatibility(output_format, stream_info, validate_format_compatibility(output_format, stream_info,
+106 -70
View File
@@ -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 json
import subprocess
from typing import Dict, List, Optional, Tuple
from .constants import FORMAT_INFO from .constants import FORMAT_INFO
from .utils import format_time from .utils import format_time
@@ -24,7 +25,10 @@ def get_audio_duration(input_file: str) -> float:
input_file input_file
] ]
result = subprocess.run(cmd, capture_output=True, text=True, check=False) result = subprocess.run(cmd, capture_output=True, text=True, check=False)
try:
return float(result.stdout.strip()) return float(result.stdout.strip())
except ValueError:
return 0.0
def has_stream_type(input_file: str, stream_type: str) -> bool: 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()) 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. 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 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. 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) '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, def get_metadata(input_file: str) -> Dict[str, any]:
drop_video, drop_subs, metadata=None): """
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. 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) '-t', format_time(duration_seconds)
] ]
# -------------------- Clear all original metadata -------------------- # Clear all original metadata.
cmd.append('-map_metadata') cmd.append('-map_metadata')
cmd.append('-1') cmd.append('-1')
# -------------------- Apply custom metadata -------------------- # Apply custom metadata.
if metadata: if metadata:
for key, value in metadata.items(): for key, value in metadata.items():
if value is not None and value != '': if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"]) cmd.extend(['-metadata', f"{key}={value}"])
# -------------------- Stream mapping -------------------- # Stream mapping.
# Map the streams we want to keep.
if drop_video and drop_subs: if drop_video and drop_subs:
cmd.extend(['-map', '0:a:0']) cmd.extend(['-map', '0:a:0'])
elif drop_video: elif drop_video:
@@ -137,7 +228,7 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
else: else:
cmd.extend(['-map', '0']) cmd.extend(['-map', '0'])
# -------------------- Audio codec -------------------- # Audio codec.
if transcode_audio: if transcode_audio:
cmd.extend(['-c:a', transcode_audio]) cmd.extend(['-c:a', transcode_audio])
if transcode_audio in ('libmp3lame', 'mp3'): if transcode_audio in ('libmp3lame', 'mp3'):
@@ -147,77 +238,22 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
else: else:
cmd.extend(['-c:a', 'copy']) cmd.extend(['-c:a', 'copy'])
# -------------------- Video codec -------------------- # Video codec.
if not drop_video and stream_info['has_video']: if not drop_video and stream_info['has_video']:
cmd.extend(['-c:v', 'copy']) cmd.extend(['-c:v', 'copy'])
else: else:
cmd.append('-vn') cmd.append('-vn')
# -------------------- Subtitle codec -------------------- # Subtitle codec.
if not drop_subs and stream_info['has_subtitle']: if not drop_subs and stream_info['has_subtitle']:
cmd.extend(['-c:s', 'copy']) cmd.extend(['-c:s', 'copy'])
else: else:
cmd.append('-sn') cmd.append('-sn')
# -------------------- Output format -------------------- # Output format.
if format_opt: if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format]) cmd.extend(['-f', ffmpeg_format])
# Overwrite output if it already exists.
cmd.extend(['-y', output_path]) cmd.extend(['-y', output_path])
return cmd 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': []}
+84 -9
View File
@@ -1,16 +1,79 @@
"""Container format decision and validation.""" """Container format decision and validation."""
from typing import Dict, Optional
from .constants import FORMAT_INFO 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. 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: Args:
stream_info: Dict from get_stream_info(). stream_info: Dict from get_stream_info().
user_format: Userrequested format (or None). user_format: Userrequested 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: Returns:
A format name that exists in FORMAT_INFO. 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: if user_format:
return user_format return user_format
# If video or subtitles exist, use MKV (which supports everything). # If input_file is provided, try to detect container and codec
if stream_info['has_video'] or stream_info['has_subtitle']: if input_file:
return 'matroska' 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
# Audioonly: 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', '') audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3': if audio_codec == 'mp3':
return 'mp3' return 'mp3'
@@ -30,7 +104,8 @@ def determine_output_format(stream_info, user_format, transcode_audio):
return 'mp4' # .m4a 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. 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 return
if info['audio_only']: 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( raise ValueError(
f"Format '{format_name}' does not support video streams. " f"Format '{format_name}' does not support video streams. "
"Please use --drop-video or choose a container that supports video." "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( raise ValueError(
f"Format '{format_name}' does not support subtitle streams. " f"Format '{format_name}' does not support subtitle streams. "
"Please use --drop-subs or choose a container that supports subtitles." "Please use --drop-subs or choose a container that supports subtitles."
+44
View File
@@ -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;
}
}
+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:
+8 -16
View File
@@ -1,29 +1,21 @@
services: services:
backend: backend:
build: image: git.vmn.su/max/audio_splitter_backend:latest
context: .
dockerfile: web/backend/Dockerfile
ports: ports:
- "8000:8000" - "${BACKEND_PORT:-8000}:8000"
volumes: volumes:
- /tmp/audio_splitter_web:/tmp/audio_splitter_web # Bind mount # Bind mount for persistent data storage.
# 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=1 - DEBUG=${DEBUG:-0}
restart: unless-stopped restart: unless-stopped
frontend: frontend:
build: image: git.vmn.su/max/audio_splitter_frontend:latest
context: ./web/frontend
dockerfile: Dockerfile
ports: ports:
- "5173:80" - "${FRONTEND_PORT:-5173}:80"
volumes:
- ./web/frontend:/app
- /app/node_modules
environment:
- BACKEND_URL=http://backend:8000
- VITE_BACKEND_URL=http://backend:8000
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped