diff --git a/web/backend/api/__init__.py b/web/backend/api/__init__.py index ae73d86..e93b233 100644 --- a/web/backend/api/__init__.py +++ b/web/backend/api/__init__.py @@ -1,3 +1,3 @@ """API route handlers.""" -from . import upload, split, split_file, status, download, formats, info +from . import upload, split, split_file, status, download, websocket, formats, info diff --git a/web/backend/api/split.py b/web/backend/api/split.py index 9967939..a3b2e64 100644 --- a/web/backend/api/split.py +++ b/web/backend/api/split.py @@ -1,4 +1,4 @@ -"""Split task endpoint.""" +"""Split task endpoint (JSON tracklist).""" from fastapi import APIRouter, HTTPException, BackgroundTasks @@ -24,6 +24,29 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks): detail=f"Task {task_id} is already {status['status']}" ) + # Build options dict from request + options = { + "container": request.container, + "audio_codec": request.audio_codec, + "video_codec": request.video_codec, + "subtitle_codec": request.subtitle_codec, + "video_quality": request.video_quality, + "drop_video": request.drop_video, + "drop_subs": request.drop_subs, + "number_tracks": request.number_tracks, + "replace_bad_chars": request.replace_bad_chars, + "replacement_char": request.replacement_char, + "bad_chars": request.bad_chars, + "skip_existing": request.skip_existing, + "output_template": request.output_template, + "album": request.album, + "comment": request.comment, + "no_comment": request.no_comment, + "comment_stream": request.comment_stream, + "merge_comments": request.merge_comments, + "comment_separator": request.comment_separator, + } + task_manager.update_task( task_id, status=TaskStatus.PROCESSING, @@ -31,9 +54,14 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks): message="Preparing to split..." ) - background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options) + background_tasks.add_task( + run_split_task, + task_id, + request.tracklist, + options + ) return SplitResponse( task_id=task_id, status=TaskStatus.PROCESSING - ) \ No newline at end of file + ) diff --git a/web/backend/api/split_file.py b/web/backend/api/split_file.py index 1c0a354..70e8627 100644 --- a/web/backend/api/split_file.py +++ b/web/backend/api/split_file.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from typing import Optional from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks @@ -13,7 +14,6 @@ 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 @@ -22,29 +22,33 @@ async def split_from_file( background_tasks: BackgroundTasks, task_id: str = Form(...), tracklist_file: UploadFile = File(...), - options: str = Form("{}"), + # New fields + container: Optional[str] = Form(None), + audio_codec: str = Form("copy"), + video_codec: str = Form("copy"), + subtitle_codec: str = Form("copy"), + video_quality: Optional[int] = Form(None), + drop_video: bool = Form(False), + drop_subs: bool = Form(False), + number_tracks: bool = Form(False), + replace_bad_chars: bool = Form(False), + replacement_char: str = Form("_"), + bad_chars: str = Form(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '), + skip_existing: bool = Form(False), + output_template: str = Form("%an-%tn.%ext"), + album: Optional[str] = Form(None), + comment: Optional[str] = Form(None), + no_comment: bool = Form(False), + comment_stream: Optional[int] = Form(None), + merge_comments: bool = Form(False), + comment_separator: str = Form("; "), + options: str = Form("{}"), # backward-compatible, but we now use explicit fields 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( @@ -52,14 +56,12 @@ async def split_from_file( 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') @@ -75,17 +77,13 @@ async def split_from_file( detail="Tracklist file is empty" ) - # Parse the tracklist using CLI logic + # Parse 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: @@ -100,7 +98,7 @@ async def split_from_file( detail="No valid tracks found in tracklist file" ) - # Convert dicts to TracklistEntry objects (same as JSON endpoint) + # Convert dicts to TracklistEntry objects try: tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts] except Exception as e: @@ -109,14 +107,28 @@ async def split_from_file( 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" - ) + # Build options dict from explicit fields (ignore `options` parameter) + options = { + "container": container, + "audio_codec": audio_codec, + "video_codec": video_codec, + "subtitle_codec": subtitle_codec, + "video_quality": video_quality, + "drop_video": drop_video, + "drop_subs": drop_subs, + "number_tracks": number_tracks, + "replace_bad_chars": replace_bad_chars, + "replacement_char": replacement_char, + "bad_chars": bad_chars, + "skip_existing": skip_existing, + "output_template": output_template, + "album": album, + "comment": comment, + "no_comment": no_comment, + "comment_stream": comment_stream, + "merge_comments": merge_comments, + "comment_separator": comment_separator, + } # Update task status task_manager.update_task( @@ -126,12 +138,11 @@ async def split_from_file( message="Preparing to split..." ) - # Start background task background_tasks.add_task( run_split_task, task_id, - tracklist_entries, # now TracklistEntry objects - options_dict + tracklist_entries, + options ) return SplitResponse( diff --git a/web/backend/models/request.py b/web/backend/models/request.py index 73ec21b..bbc8e83 100644 --- a/web/backend/models/request.py +++ b/web/backend/models/request.py @@ -17,19 +17,16 @@ class TracklistEntry(BaseModel): @validator("ts") def validate_timestamp(cls, v: str) -> str: - """Basic timestamp validation (format and range).""" v = v.strip() if not v: raise ValueError("Timestamp cannot be empty") - # Check for range format (start-end) if "-" in v: parts = v.split("-", 1) start = parts[0].strip() end = parts[1].strip() if not start or not end: raise ValueError("Invalid range format. Expected 'start-end'") - # Validate each part with the same logic for ts in [start, end]: cls._validate_single_timestamp(ts) else: @@ -39,7 +36,6 @@ class TracklistEntry(BaseModel): @staticmethod def _validate_single_timestamp(ts: str) -> None: - """Validate a single timestamp (mm:ss or HH:MM:SS).""" parts = ts.split(":") if len(parts) not in (2, 3): raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS") @@ -55,4 +51,30 @@ class SplitRequest(BaseModel): task_id: str = Field(..., description="Task ID from upload") tracklist: List[TracklistEntry] = Field(..., description="List of tracks") - options: dict = Field(default_factory=dict, description="All CLI options") \ No newline at end of file + + # Container and codec options + container: Optional[str] = Field(None, description="Output container (auto-detect if None)") + audio_codec: Optional[str] = Field("copy", description="Audio codec (copy or encoder name)") + video_codec: Optional[str] = Field("copy", description="Video codec (copy or encoder name)") + subtitle_codec: Optional[str] = Field("copy", description="Subtitle codec (copy or encoder name)") + video_quality: Optional[int] = Field(None, description="Video quality (encoder-specific integer)") + + # Stream handling + drop_video: bool = Field(False, description="Remove video streams") + drop_subs: bool = Field(False, description="Remove subtitle streams") + + # Filename options + number_tracks: bool = Field(False, description="Prepend track numbers") + replace_bad_chars: bool = Field(False, description="Replace bad characters") + replacement_char: str = Field("_", description="Replacement character") + bad_chars: str = Field(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ', description="Bad characters to replace") + skip_existing: bool = Field(False, description="Skip existing files") + output_template: str = Field("%an-%tn.%ext", description="Output filename template") + + # Metadata options + album: Optional[str] = Field(None, description="Album name") + comment: Optional[str] = Field(None, description="Comment") + no_comment: bool = Field(False, description="Ignore comment") + comment_stream: Optional[int] = Field(None, description="Comment stream index") + merge_comments: bool = Field(False, description="Merge all comments") + comment_separator: str = Field("; ", description="Separator for merged comments") diff --git a/web/backend/services/__init__.py b/web/backend/services/__init__.py index ee78a45..de2060f 100644 --- a/web/backend/services/__init__.py +++ b/web/backend/services/__init__.py @@ -1 +1 @@ -"""Business logic services.""" \ No newline at end of file +"""Business logic services.""" diff --git a/web/backend/services/splitter.py b/web/backend/services/splitter.py index 6c9b501..fa82df8 100644 --- a/web/backend/services/splitter.py +++ b/web/backend/services/splitter.py @@ -2,10 +2,10 @@ import os import sys -import time +import shutil from pathlib import Path -from types import SimpleNamespace -from typing import Dict, List, Any +from typing import List, Dict, Any +import time from backend.config import settings from backend.services.task_manager import task_manager @@ -13,31 +13,12 @@ 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(): @@ -45,7 +26,6 @@ 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 = { @@ -58,25 +38,30 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[ } tracks.append(track_dict) - # Build args namespace using defaults where options not provided + from types import SimpleNamespace + + # Build args namespace using the new fields args = SimpleNamespace( - 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 + container=options.get("container"), # None -> auto-detect + audio_codec=options.get("audio_codec", "copy"), + video_codec=options.get("video_codec", "copy"), + subtitle_codec=options.get("subtitle_codec", "copy"), + video_quality=options.get("video_quality"), + 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, ) project_root = Path(__file__).parent.parent.parent.parent @@ -85,7 +70,25 @@ 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...") + # Detect attached picture and extract if applicable + cover_image_path = None + if not args.drop_video: + from audio_splitter.ffmpeg import is_attached_picture, extract_cover_image, get_stream_info + input_path_str = str(input_path) + stream_info = get_stream_info(input_path_str) + if stream_info.get('has_video') and is_attached_picture(input_path_str): + cover_image_path = output_dir / 'cover.png' + if extract_cover_image(input_path_str, str(cover_image_path)): + print(f"Extracted cover image for task {task_id}") + else: + cover_image_path = None + + # Attach cover image to args + args.cover_image = str(cover_image_path) if cover_image_path else None + + task_manager.update_task_with_progress( + task_id, progress=10, message="Starting split..." + ) split_audio(str(input_path), str(output_dir), tracks, args)