9 Commits

19 changed files with 547 additions and 329 deletions
-57
View File
@@ -1,57 +0,0 @@
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"]
+35
View File
@@ -0,0 +1,35 @@
"""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
+50 -116
View File
@@ -1,133 +1,67 @@
"""Commandline interface and entry point."""
import argparse
import os
import sys
import subprocess
import argparse
from .constants import DEFAULT_BAD_CHARS
from .tracklist import read_tracklist, parse_format
from .defaults import (
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,
DEFAULT_TRANSCODE_TO,
)
from .core import split_audio
from .tracklist import read_tracklist, parse_format
def main():
"""Parse arguments and start the splitting process."""
parser = argparse.ArgumentParser(
description="Split an audio file into tracks using a tracklist.",
epilog="Tracklist format: mm:ss track_name - author_name"
)
# Positional arguments.
parser.add_argument('input_file', help='Input audio/video file')
parser = argparse.ArgumentParser(description="Split audio file using a tracklist.")
parser.add_argument('input_file', help='Input audio file')
parser.add_argument('tracklist_file', help='Tracklist file')
# Optional arguments.
parser.add_argument(
'--output-dir', '-o',
help='Output directory for split tracks (default: <input_basename>_splits)'
)
parser.add_argument(
'--format',
help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)'
)
parser.add_argument(
'--transcode-to',
help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)'
)
parser.add_argument(
'--drop-video',
action='store_true',
help='Remove video streams from output'
)
parser.add_argument(
'--drop-subs',
action='store_true',
help='Remove subtitle streams from output'
)
parser.add_argument(
'--number-tracks',
action='store_true',
help='Prepend track number to output filenames (convenience; use %%num in template for full control)'
)
parser.add_argument(
'--replace-bad-chars',
action='store_true',
help='Replace problematic characters in filenames (default: off)'
)
parser.add_argument(
'--replacement-char',
default='_',
help='Character used as replacement (default: "_")'
)
parser.add_argument(
'--bad-chars',
default=DEFAULT_BAD_CHARS,
help='String of characters to replace (default includes space and single quote)'
)
parser.add_argument(
'--skip-existing',
action='store_true',
help='Skip extraction if output file already exists (default: overwrite)'
)
parser.add_argument(
'--tracklist-format',
default='%ts %tn - %an',
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"'
)
parser.add_argument(
'--output-template',
default='%an-%tn.%ext',
help='Template for output filenames using placeholders: '
'%%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension), %%num (track number). '
'Default: "%%an-%%tn.%%ext"'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Parse and display the tracklist without splitting any files'
)
# Output options
parser.add_argument('--format', default=DEFAULT_FORMAT, help=f"Output container format (default: {DEFAULT_FORMAT})")
parser.add_argument('--transcode-to', default=DEFAULT_TRANSCODE_TO, help="Audio codec to transcode to (default: copy)")
parser.add_argument('--drop-video', action='store_true', default=DEFAULT_DROP_VIDEO, help="Drop video streams")
parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, help="Drop subtitle streams")
# Metadata options.
parser.add_argument(
'--album',
help='Set album name in output metadata (overrides parsed %%al and original album)'
)
parser.add_argument(
'--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,
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: "; ")'
)
# Filename options
parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, help="Prepend track numbers")
parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, help=f"Output filename template (default: {DEFAULT_OUTPUT_TEMPLATE})")
parser.add_argument('--replace-bad-chars', action='store_true', default=DEFAULT_REPLACE_BAD_CHARS, help="Replace bad characters")
parser.add_argument('--replacement-char', default=DEFAULT_REPLACEMENT_CHAR, help=f"Replacement character (default: {DEFAULT_REPLACEMENT_CHAR})")
parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, help=f"Bad characters to replace (default: {DEFAULT_BAD_CHARS})")
parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, help="Skip existing output files")
# 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)'
)
# Metadata options
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=f"Separator for merged comments (default: {DEFAULT_COMMENT_SEPARATOR})")
# Tracklist format
parser.add_argument('--tracklist-format', default=DEFAULT_TRACKLIST_FORMAT, help=f"Tracklist format (default: {DEFAULT_TRACKLIST_FORMAT})")
# Other
parser.add_argument('--delete-original', action='store_true', default=DEFAULT_DELETE_ORIGINAL, help="Delete original file after split")
parser.add_argument('--dry-run', action='store_true', help="Parse and display tracklist without splitting")
args = parser.parse_args()
-37
View File
@@ -1,37 +0,0 @@
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:
+10 -5
View File
@@ -1,19 +1,24 @@
services:
backend:
image: git.vmn.su/max/audio_splitter_backend:latest
build:
context: .
dockerfile: web/backend/Dockerfile
#image: max/audio_splitter_backend:latest
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
# 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"
#- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
- "/tmp/test:/tmp/audio_splitter_web"
environment:
- PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0}
restart: unless-stopped
frontend:
image: git.vmn.su/max/audio_splitter_frontend:latest
build:
context: .
dockerfile: web/frontend/Dockerfile
#image: max/audio_splitter_frontend:latest
ports:
- "${FRONTEND_PORT:-5173}:80"
depends_on:
-18
View File
@@ -1,18 +0,0 @@
#!/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
@@ -0,0 +1,79 @@
#!/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()
+1 -2
View File
@@ -1,4 +1,3 @@
"""API route handlers."""
from . import upload, split, status, download
# websocket is imported directly in main.py to avoid circular import
from . import upload, split, split_file, status, download, formats, info
+39 -2
View File
@@ -5,7 +5,12 @@ from fastapi import APIRouter, HTTPException
from backend.config import settings
from backend.services.file_manager import FileManager
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"])
@@ -13,7 +18,8 @@ router = APIRouter(prefix="/api", tags=["info"])
@router.get("/info/{task_id}")
async def get_task_info(task_id: str):
"""
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
Return stream information (has_audio, has_video, has_subtitle, audio_codec)
for the uploaded file.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
@@ -33,3 +39,34 @@ async def get_task_info(task_id: str):
}
except Exception as 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)}")
+140
View File
@@ -0,0 +1,140 @@
"""Endpoint for splitting with a tracklist file upload."""
import json
from pathlib import Path
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"])
# Import CLI tracklist parsing functions
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(...),
options: str = Form("{}"),
tracklist_format: str = Form("%ts %tn - %an"),
):
"""
Start a splitting task using a tracklist file.
This endpoint accepts a plain text tracklist file (like the CLI does)
and parses it using the same logic.
Args:
task_id: Task ID from upload.
tracklist_file: Tracklist file (text/plain).
options: JSON string of all CLI options.
tracklist_format: Format string for parsing (default: "%ts %tn - %an").
Returns:
SplitResponse with task_id and status.
"""
# Validate task exists
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
# Check if task is already processing
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']}"
)
# Validate file type
if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException(
status_code=400,
detail="Tracklist file must be a text file (.txt or .text)"
)
# Read and parse the tracklist file
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 the tracklist using CLI logic
try:
# Parse the format string into tokens
tokens = parse_format(tracklist_format)
# Write the content to a temporary file (read_tracklist expects a file path)
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')
# Parse the tracklist file using CLI logic
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 (same as JSON endpoint)
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)}"
)
# Parse options JSON
try:
options_dict = json.loads(options)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="Invalid JSON in options field"
)
# Update task status
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
# Start background task
background_tasks.add_task(
run_split_task,
task_id,
tracklist_entries, # now TracklistEntry objects
options_dict
)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+4 -3
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.config import settings
from backend.api import upload, split, status, download, websocket, formats, info
from backend.api import upload, split, split_file, status, download, websocket, formats, info
from backend.services import progress_publisher
app = FastAPI(
@@ -33,11 +33,12 @@ async def startup_event():
# Include routers
app.include_router(upload.router)
app.include_router(split.router)
app.include_router(split_file.router) # NEW
app.include_router(status.router)
app.include_router(download.router)
app.include_router(websocket.router)
app.include_router(formats.router) # new
app.include_router(info.router) # new
app.include_router(formats.router)
app.include_router(info.router)
@app.get("/")
+45 -33
View File
@@ -2,10 +2,10 @@
import os
import sys
import shutil
from pathlib import Path
from typing import List, Dict, Any
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Dict, List, Any
from backend.config import settings
from backend.services.task_manager import task_manager
@@ -13,12 +13,31 @@ from backend.services.file_manager import FileManager
from backend.models.request import TracklistEntry
from backend.models.response import TaskStatus
# Import central defaults
from audio_splitter.defaults 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_DELETE_ORIGINAL,
DEFAULT_TRANSCODE_TO,
)
def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
try:
task_manager.update_task_with_progress(
task_id, progress=5, message="Initializing..."
)
task_manager.update_task_with_progress(task_id, progress=5, message="Initializing...")
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
@@ -26,6 +45,7 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
output_dir = FileManager.ensure_output_dir(task_id)
# Convert tracklist to dicts (CLI format)
tracks = []
for entry in tracklist:
track_dict = {
@@ -38,26 +58,25 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
}
tracks.append(track_dict)
from types import SimpleNamespace
# Build args namespace using defaults where options not provided
args = SimpleNamespace(
format=options.get("format", "mp3"),
transcode_to=options.get("transcode_to", None),
drop_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False),
replace_bad_chars=options.get("replace_bad_chars", False),
replacement_char=options.get("replacement_char", "_"),
bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
skip_existing=options.get("skip_existing", False),
output_template=options.get("output_template", "%an-%tn.%ext"),
album=options.get("album", None),
comment=options.get("comment", None),
no_comment=options.get("no_comment", False),
comment_stream=options.get("comment_stream", None),
merge_comments=options.get("merge_comments", False),
comment_separator=options.get("comment_separator", "; "),
delete_original=False,
format=options.get("format", DEFAULT_FORMAT),
transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO),
drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO),
drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS),
number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS),
replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS),
replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR),
bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS),
skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING),
output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE),
album=options.get("album", DEFAULT_ALBUM),
comment=options.get("comment", DEFAULT_COMMENT),
no_comment=options.get("no_comment", DEFAULT_NO_COMMENT),
comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM),
merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS),
comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR),
delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web
)
project_root = Path(__file__).parent.parent.parent.parent
@@ -66,17 +85,12 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from audio_splitter.core import split_audio
task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..."
)
task_manager.update_task_with_progress(task_id, progress=10, message="Starting split...")
# Run the split
split_audio(str(input_path), str(output_dir), tracks, args)
# Get output files
output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress(
task_id,
progress=100,
@@ -84,13 +98,11 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
status=TaskStatus.DONE
)
# Add tracks to the task state
task_manager.update_task(
task_id,
tracks=output_files
)
# Give WebSocket time to send the final message
time.sleep(0.5)
except Exception as e:
+27 -12
View File
@@ -1,27 +1,42 @@
# Stage 1: Build
# web/frontend/Dockerfile
# Build context must be the project root.
FROM node:20-alpine AS builder
# Install Python for the generation script
RUN apk add --no-cache python3 py3-pip
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
# Copy frontend package files and install dependencies
COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm install
# Copy the application code and build
COPY . .
# Copy the frontend source code
COPY web/frontend/ .
# 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
# Stage 2: Production (nginx)
FROM nginx:alpine
# Copy built assets from builder
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
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+4 -1
View File
@@ -4,6 +4,9 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"generate": "python scripts/generate_ts_defaults.py",
"predev": "npm run generate",
"prebuild": "npm run generate",
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
@@ -33,4 +36,4 @@
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
}
+5
View File
@@ -63,3 +63,8 @@ export const getTaskInfo = async (task_id: string): Promise<{
const response = await api.get(`/info/${task_id}`)
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
}
+16 -11
View File
@@ -115,32 +115,37 @@ export const OptionsPanel: React.FC = () => {
<Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Format"
label="Container"
select
value={options.format}
onChange={handleFormatChange}
fullWidth
size="small"
error={!!formatError}
helperText={
formatError || "Determines the output file extension and container structure."
}
>
<MenuItem value="mp3">MP3</MenuItem>
<MenuItem value="m4a">M4A</MenuItem>
<MenuItem value="mkv">MKV</MenuItem>
<MenuItem value="mp4">MP4</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>
<MenuItem value="mp3">MP3 (.mp3)</MenuItem>
<MenuItem value="m4a">M4A (.m4a)</MenuItem>
<MenuItem value="mkv">MKV (.mkv)</MenuItem>
<MenuItem value="mp4">MP4 (.mp4)</MenuItem>
<MenuItem value="ogg">OGG (.ogg)</MenuItem>
<MenuItem value="opus">OPUS (.opus)</MenuItem>
<MenuItem value="flac">FLAC (.flac)</MenuItem>
<MenuItem value="wav">WAV (.wav)</MenuItem>
<MenuItem value="aac">AAC (.aac)</MenuItem>
</TextField>
{/* Transcode option remains unchanged but clearly labeled */}
<TextField
label="Transcode to"
label="Audio Codec (Transcode)"
select
value={options.transcode_to || ''}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
fullWidth
size="small"
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original."
>
<MenuItem value="">Copy (no transcoding)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
+41 -12
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore'
@@ -10,6 +10,9 @@ export const TracklistEditor: React.FC = () => {
const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const lineNumbersRef = useRef<HTMLDivElement>(null)
const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries)
@@ -23,10 +26,29 @@ export const TracklistEditor: React.FC = () => {
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(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const file = acceptedFiles[0]
const reader = new FileReader()
reader.onload = (event) => {
@@ -48,13 +70,6 @@ export const TracklistEditor: React.FC = () => {
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
return (
@@ -82,12 +97,13 @@ export const TracklistEditor: React.FC = () => {
</Typography>
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 2, position: 'relative' }}>
{/* Line numbers column */}
<Box
ref={lineNumbersRef}
sx={{
minWidth: 40,
maxWidth: 40,
maxWidth: 60, // Allow more space for 3-digit numbers
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
@@ -95,6 +111,11 @@ export const TracklistEditor: React.FC = () => {
textAlign: 'right',
userSelect: 'none',
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) => (
@@ -102,7 +123,7 @@ export const TracklistEditor: React.FC = () => {
))}
</Box>
{/* Editor text area */}
{/* Editor text area now with horizontal scroll and no wrap */}
<TextField
multiline
fullWidth
@@ -112,11 +133,19 @@ export const TracklistEditor: React.FC = () => {
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`}
variant="outlined"
inputRef={textAreaRef}
sx={{
'& .MuiInputBase-root': {
fontFamily: 'monospace',
fontSize: '14px',
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}
+14 -3
View File
@@ -3,7 +3,8 @@ import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
import { useUploadStore } from '../stores/uploadStore'
import { uploadFile, getTaskInfo } from '../api/client'
import { useOptionsStore } from '../stores/optionsStore' // NEW
import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW
import { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
@@ -30,6 +31,7 @@ export const UploadZone: React.FC = () => {
} = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore()
const { setOptions } = useOptionsStore() // NEW
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
@@ -65,9 +67,18 @@ export const UploadZone: React.FC = () => {
setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle)
setAudioCodec(info.audio_codec)
// Fetch recommended format and update options
try {
const rec = await getRecommendedFormat(taskId)
// Update only the format; keep other options (e.g., transcode_to) as defaults
setOptions({ format: rec.format })
} catch (err) {
console.warn('Failed to fetch recommended format, using default', err)
}
} catch (err) {
console.error('Failed to fetch stream info:', err)
// Don't block the upload flow if this fails; we'll just assume no video
// Don't block upload flow; user can manually change options
}
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed')
@@ -78,7 +89,7 @@ export const UploadZone: React.FC = () => {
setFileSize(0)
}
},
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore, setHasVideo, setHasAudio, setHasSubtitle, setAudioCodec, setOptions]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
+37 -17
View File
@@ -1,24 +1,44 @@
// web/frontend/src/stores/optionsStore.ts
import { create } from 'zustand'
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_TRANSCODE_TO,
DEFAULT_TRACKLIST_FORMAT,
} from '../constants/generated'
const DEFAULT_OPTIONS: SplitOptions = {
format: 'mp3',
transcode_to: '',
drop_video: false,
drop_subs: false,
number_tracks: false,
replace_bad_chars: false,
replacement_char: '_',
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ',
skip_existing: false,
output_template: '%an-%tn.%ext',
album: '',
comment: '',
no_comment: false,
comment_stream: null,
merge_comments: false,
comment_separator: '; ',
tracklist_format: '%ts %tn - %an', // NEW
format: DEFAULT_FORMAT,
transcode_to: DEFAULT_TRANSCODE_TO ?? '',
drop_video: DEFAULT_DROP_VIDEO,
drop_subs: DEFAULT_DROP_SUBS,
number_tracks: DEFAULT_NUMBER_TRACKS,
replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS,
replacement_char: DEFAULT_REPLACEMENT_CHAR,
bad_chars: DEFAULT_BAD_CHARS,
skip_existing: DEFAULT_SKIP_EXISTING,
output_template: DEFAULT_OUTPUT_TEMPLATE,
album: DEFAULT_ALBUM ?? '',
comment: DEFAULT_COMMENT ?? '',
no_comment: DEFAULT_NO_COMMENT,
comment_stream: DEFAULT_COMMENT_STREAM,
merge_comments: DEFAULT_MERGE_COMMENTS,
comment_separator: DEFAULT_COMMENT_SEPARATOR,
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
}
interface OptionsState {