diff --git a/audio_splitter/defaults.py b/audio_splitter/defaults.py new file mode 100644 index 0000000..b6a66d4 --- /dev/null +++ b/audio_splitter/defaults.py @@ -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 diff --git a/audio_splitter/main.py b/audio_splitter/main.py index 535cc47..4c83bc9 100644 --- a/audio_splitter/main.py +++ b/audio_splitter/main.py @@ -1,133 +1,67 @@ """Command‑line 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: _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 (0‑based). 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() diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml deleted file mode 100644 index 4c9b1fa..0000000 --- a/docker-compose.override.yaml +++ /dev/null @@ -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: diff --git a/docker-compose.yaml b/docker-compose.yaml index 48151a8..7963630 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/scripts/generate_ts_defaults.py b/scripts/generate_ts_defaults.py new file mode 100644 index 0000000..9274610 --- /dev/null +++ b/scripts/generate_ts_defaults.py @@ -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() diff --git a/web/backend/services/splitter.py b/web/backend/services/splitter.py index 23be35b..6c9b501 100644 --- a/web/backend/services/splitter.py +++ b/web/backend/services/splitter.py @@ -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: diff --git a/web/frontend/Dockerfile b/web/frontend/Dockerfile index 3db19ff..5aab5dd 100644 --- a/web/frontend/Dockerfile +++ b/web/frontend/Dockerfile @@ -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;"] diff --git a/web/frontend/package.json b/web/frontend/package.json index d9877bc..c592d22 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/web/frontend/src/stores/optionsStore.ts b/web/frontend/src/stores/optionsStore.ts index 1d6ca29..ce2970b 100644 --- a/web/frontend/src/stores/optionsStore.ts +++ b/web/frontend/src/stores/optionsStore.ts @@ -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 {