5 Commits

14 changed files with 432 additions and 301 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.""" """Commandline interface and entry point."""
import argparse
import os import os
import sys import sys
import subprocess import subprocess
import argparse
from .constants import DEFAULT_BAD_CHARS 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 .core import split_audio
from .tracklist import read_tracklist, parse_format
def main(): def main():
"""Parse arguments and start the splitting process.""" parser = argparse.ArgumentParser(description="Split audio file using a tracklist.")
parser = argparse.ArgumentParser( parser.add_argument('input_file', help='Input audio file')
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.add_argument('tracklist_file', help='Tracklist file') parser.add_argument('tracklist_file', help='Tracklist file')
# Optional arguments. # Output options
parser.add_argument( parser.add_argument('--format', default=DEFAULT_FORMAT, help=f"Output container format (default: {DEFAULT_FORMAT})")
'--output-dir', '-o', parser.add_argument('--transcode-to', default=DEFAULT_TRANSCODE_TO, help="Audio codec to transcode to (default: copy)")
help='Output directory for split tracks (default: <input_basename>_splits)' 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")
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'
)
# Metadata options. # Filename options
parser.add_argument( parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, help="Prepend track numbers")
'--album', parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, help=f"Output filename template (default: {DEFAULT_OUTPUT_TEMPLATE})")
help='Set album name in output metadata (overrides parsed %%al and original album)' 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( parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, help=f"Bad characters to replace (default: {DEFAULT_BAD_CHARS})")
'--comment', parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, help="Skip existing output files")
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: "; ")'
)
# NEW: Delete original after successful split. # Metadata options
parser.add_argument( parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
'--delete-original', parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
action='store_true', parser.add_argument('--no-comment', action='store_true', default=DEFAULT_NO_COMMENT, help="Ignore comment")
help='Delete the original input file after successful splitting (default: keep)' 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() 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: services:
backend: backend:
image: git.vmn.su/max/audio_splitter_backend:latest build:
context: .
dockerfile: web/backend/Dockerfile
#image: max/audio_splitter_backend:latest
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
volumes: volumes:
# Bind mount for persistent data storage. #- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
# Set BACKEND_DATA_DIR in .env to point to your data directory. - "/tmp/test:/tmp/audio_splitter_web"
- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0} - DEBUG=${DEBUG:-0}
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: git.vmn.su/max/audio_splitter_frontend:latest build:
context: .
dockerfile: web/frontend/Dockerfile
#image: max/audio_splitter_frontend:latest
ports: ports:
- "${FRONTEND_PORT:-5173}:80" - "${FRONTEND_PORT:-5173}:80"
depends_on: depends_on:
-18
View File
@@ -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.""" """API route handlers."""
from . import upload, split, status, download from . import upload, split, split_file, status, download, formats, info
# websocket is imported directly in main.py to avoid circular import
+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 fastapi.middleware.cors import CORSMiddleware
from backend.config import settings 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 from backend.services import progress_publisher
app = FastAPI( app = FastAPI(
@@ -33,11 +33,12 @@ async def startup_event():
# Include routers # Include routers
app.include_router(upload.router) app.include_router(upload.router)
app.include_router(split.router) app.include_router(split.router)
app.include_router(split_file.router) # NEW
app.include_router(status.router) app.include_router(status.router)
app.include_router(download.router) app.include_router(download.router)
app.include_router(websocket.router) app.include_router(websocket.router)
app.include_router(formats.router) # new app.include_router(formats.router)
app.include_router(info.router) # new app.include_router(info.router)
@app.get("/") @app.get("/")
+45 -33
View File
@@ -2,10 +2,10 @@
import os import os
import sys import sys
import shutil
from pathlib import Path
from typing import List, Dict, Any
import time import time
from pathlib import Path
from types import SimpleNamespace
from typing import Dict, List, Any
from backend.config import settings from backend.config import settings
from backend.services.task_manager import task_manager 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.request import TracklistEntry
from backend.models.response import TaskStatus 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: def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
try: try:
task_manager.update_task_with_progress( task_manager.update_task_with_progress(task_id, progress=5, message="Initializing...")
task_id, progress=5, message="Initializing..."
)
input_path = FileManager.get_input_path(task_id) input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists(): 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) output_dir = FileManager.ensure_output_dir(task_id)
# Convert tracklist to dicts (CLI format)
tracks = [] tracks = []
for entry in tracklist: for entry in tracklist:
track_dict = { track_dict = {
@@ -38,26 +58,25 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
} }
tracks.append(track_dict) tracks.append(track_dict)
from types import SimpleNamespace # Build args namespace using defaults where options not provided
args = SimpleNamespace( args = SimpleNamespace(
format=options.get("format", "mp3"), format=options.get("format", DEFAULT_FORMAT),
transcode_to=options.get("transcode_to", None), transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO),
drop_video=options.get("drop_video", False), drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO),
drop_subs=options.get("drop_subs", False), drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS),
number_tracks=options.get("number_tracks", False), number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS),
replace_bad_chars=options.get("replace_bad_chars", False), replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS),
replacement_char=options.get("replacement_char", "_"), replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR),
bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '), bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS),
skip_existing=options.get("skip_existing", False), skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING),
output_template=options.get("output_template", "%an-%tn.%ext"), output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE),
album=options.get("album", None), album=options.get("album", DEFAULT_ALBUM),
comment=options.get("comment", None), comment=options.get("comment", DEFAULT_COMMENT),
no_comment=options.get("no_comment", False), no_comment=options.get("no_comment", DEFAULT_NO_COMMENT),
comment_stream=options.get("comment_stream", None), comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM),
merge_comments=options.get("merge_comments", False), merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS),
comment_separator=options.get("comment_separator", "; "), comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR),
delete_original=False, delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web
) )
project_root = Path(__file__).parent.parent.parent.parent 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 from audio_splitter.core import split_audio
task_manager.update_task_with_progress( task_manager.update_task_with_progress(task_id, progress=10, message="Starting split...")
task_id, progress=10, message="Starting split..."
)
# Run the split
split_audio(str(input_path), str(output_dir), tracks, args) split_audio(str(input_path), str(output_dir), tracks, args)
# Get output files
output_files = FileManager.get_output_files(task_id) output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress( task_manager.update_task_with_progress(
task_id, task_id,
progress=100, progress=100,
@@ -84,13 +98,11 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
status=TaskStatus.DONE status=TaskStatus.DONE
) )
# Add tracks to the task state
task_manager.update_task( task_manager.update_task(
task_id, task_id,
tracks=output_files tracks=output_files
) )
# Give WebSocket time to send the final message
time.sleep(0.5) time.sleep(0.5)
except Exception as e: except Exception as e:
+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 FROM node:20-alpine AS builder
# Install Python for the generation script
RUN apk add --no-cache python3 py3-pip
WORKDIR /app WORKDIR /app
# Install dependencies # Copy frontend package files and install dependencies
COPY package.json package-lock.json* ./ COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm install RUN npm install
# Copy the application code and build # Copy the frontend source code
COPY . . 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 RUN npm run build
# Stage 2: Production (nginx) # Stage 2: Production (nginx)
FROM nginx:alpine FROM nginx:alpine
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY web/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port
EXPOSE 80 EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+3
View File
@@ -4,6 +4,9 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate": "python scripts/generate_ts_defaults.py",
"predev": "npm run generate",
"prebuild": "npm run generate",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+37 -17
View File
@@ -1,24 +1,44 @@
// web/frontend/src/stores/optionsStore.ts
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions } from '../types' import { SplitOptions } from '../types'
import {
DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_NO_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_MERGE_COMMENTS,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_TRANSCODE_TO,
DEFAULT_TRACKLIST_FORMAT,
} from '../constants/generated'
const DEFAULT_OPTIONS: SplitOptions = { const DEFAULT_OPTIONS: SplitOptions = {
format: 'mp3', format: DEFAULT_FORMAT,
transcode_to: '', transcode_to: DEFAULT_TRANSCODE_TO ?? '',
drop_video: false, drop_video: DEFAULT_DROP_VIDEO,
drop_subs: false, drop_subs: DEFAULT_DROP_SUBS,
number_tracks: false, number_tracks: DEFAULT_NUMBER_TRACKS,
replace_bad_chars: false, replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS,
replacement_char: '_', replacement_char: DEFAULT_REPLACEMENT_CHAR,
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ', bad_chars: DEFAULT_BAD_CHARS,
skip_existing: false, skip_existing: DEFAULT_SKIP_EXISTING,
output_template: '%an-%tn.%ext', output_template: DEFAULT_OUTPUT_TEMPLATE,
album: '', album: DEFAULT_ALBUM ?? '',
comment: '', comment: DEFAULT_COMMENT ?? '',
no_comment: false, no_comment: DEFAULT_NO_COMMENT,
comment_stream: null, comment_stream: DEFAULT_COMMENT_STREAM,
merge_comments: false, merge_comments: DEFAULT_MERGE_COMMENTS,
comment_separator: '; ', comment_separator: DEFAULT_COMMENT_SEPARATOR,
tracklist_format: '%ts %tn - %an', // NEW tracklist_format: DEFAULT_TRACKLIST_FORMAT,
} }
interface OptionsState { interface OptionsState {