FEATURE: single source of truth for default values added

This commit is contained in:
2026-08-20 10:28:07 +05:00
parent 05fcc8ea29
commit 140a0fbc46
9 changed files with 287 additions and 220 deletions
+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:
+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()
+45 -32
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,28 +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 from options.
# IMPORTANT: format is None if not specified -> core will auto-detect.
# Build args namespace using defaults where options not provided
args = SimpleNamespace(
format=options.get("format"),
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
@@ -68,9 +85,7 @@ 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...")
split_audio(str(input_path), str(output_dir), tracks, args)
@@ -83,13 +98,11 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
status=TaskStatus.DONE
)
# Add tracks to the task state (for download and status queries)
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;"]
+3
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",
+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 {