FEATURE: propogate improvements from CLI version to web-backend

This commit is contained in:
2026-08-28 13:24:46 +05:00
parent 748532be7c
commit 834239674a
6 changed files with 156 additions and 92 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""API route handlers.""" """API route handlers."""
from . import upload, split, split_file, status, download, formats, info from . import upload, split, split_file, status, download, websocket, formats, info
+31 -3
View File
@@ -1,4 +1,4 @@
"""Split task endpoint.""" """Split task endpoint (JSON tracklist)."""
from fastapi import APIRouter, HTTPException, BackgroundTasks 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']}" 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_manager.update_task(
task_id, task_id,
status=TaskStatus.PROCESSING, status=TaskStatus.PROCESSING,
@@ -31,9 +54,14 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
message="Preparing to split..." 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( return SplitResponse(
task_id=task_id, task_id=task_id,
status=TaskStatus.PROCESSING status=TaskStatus.PROCESSING
) )
+48 -37
View File
@@ -2,6 +2,7 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks 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"]) router = APIRouter(prefix="/api", tags=["split"])
# Import CLI tracklist parsing functions
from audio_splitter.tracklist import parse_format, read_tracklist from audio_splitter.tracklist import parse_format, read_tracklist
@@ -22,29 +22,33 @@ async def split_from_file(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
task_id: str = Form(...), task_id: str = Form(...),
tracklist_file: UploadFile = File(...), 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"), 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 # Validate task exists
if not task_manager.has_task(task_id): if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found") 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) status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE): if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException( raise HTTPException(
@@ -52,14 +56,12 @@ async def split_from_file(
detail=f"Task {task_id} is already {status['status']}" detail=f"Task {task_id} is already {status['status']}"
) )
# Validate file type
if not tracklist_file.filename.endswith(('.txt', '.text')): if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Tracklist file must be a text file (.txt or .text)" detail="Tracklist file must be a text file (.txt or .text)"
) )
# Read and parse the tracklist file
try: try:
content = await tracklist_file.read() content = await tracklist_file.read()
text = content.decode('utf-8') text = content.decode('utf-8')
@@ -75,17 +77,13 @@ async def split_from_file(
detail="Tracklist file is empty" detail="Tracklist file is empty"
) )
# Parse the tracklist using CLI logic # Parse tracklist using CLI logic
try: try:
# Parse the format string into tokens
tokens = parse_format(tracklist_format) 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 = settings.temp_dir / task_id / "tracklist.txt"
temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True) temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True)
temp_tracklist_path.write_text(text, encoding='utf-8') 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) tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
except Exception as e: except Exception as e:
@@ -100,7 +98,7 @@ async def split_from_file(
detail="No valid tracks found in tracklist file" detail="No valid tracks found in tracklist file"
) )
# Convert dicts to TracklistEntry objects (same as JSON endpoint) # Convert dicts to TracklistEntry objects
try: try:
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts] tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
except Exception as e: except Exception as e:
@@ -109,14 +107,28 @@ async def split_from_file(
detail=f"Invalid tracklist data: {str(e)}" detail=f"Invalid tracklist data: {str(e)}"
) )
# Parse options JSON # Build options dict from explicit fields (ignore `options` parameter)
try: options = {
options_dict = json.loads(options) "container": container,
except json.JSONDecodeError: "audio_codec": audio_codec,
raise HTTPException( "video_codec": video_codec,
status_code=400, "subtitle_codec": subtitle_codec,
detail="Invalid JSON in options field" "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 # Update task status
task_manager.update_task( task_manager.update_task(
@@ -126,12 +138,11 @@ async def split_from_file(
message="Preparing to split..." message="Preparing to split..."
) )
# Start background task
background_tasks.add_task( background_tasks.add_task(
run_split_task, run_split_task,
task_id, task_id,
tracklist_entries, # now TracklistEntry objects tracklist_entries,
options_dict options
) )
return SplitResponse( return SplitResponse(
+27 -5
View File
@@ -17,19 +17,16 @@ class TracklistEntry(BaseModel):
@validator("ts") @validator("ts")
def validate_timestamp(cls, v: str) -> str: def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip() v = v.strip()
if not v: if not v:
raise ValueError("Timestamp cannot be empty") raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v: if "-" in v:
parts = v.split("-", 1) parts = v.split("-", 1)
start = parts[0].strip() start = parts[0].strip()
end = parts[1].strip() end = parts[1].strip()
if not start or not end: if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'") raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]: for ts in [start, end]:
cls._validate_single_timestamp(ts) cls._validate_single_timestamp(ts)
else: else:
@@ -39,7 +36,6 @@ class TracklistEntry(BaseModel):
@staticmethod @staticmethod
def _validate_single_timestamp(ts: str) -> None: def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":") parts = ts.split(":")
if len(parts) not in (2, 3): if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS") 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") task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks") tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
options: dict = Field(default_factory=dict, description="All CLI options")
# 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")
+1 -1
View File
@@ -1 +1 @@
"""Business logic services.""" """Business logic services."""
+48 -45
View File
@@ -2,10 +2,10 @@
import os import os
import sys import sys
import time import shutil
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from typing import List, Dict, Any
from typing import Dict, List, Any import time
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,31 +13,12 @@ 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_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) 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():
@@ -45,7 +26,6 @@ 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 = {
@@ -58,25 +38,30 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
} }
tracks.append(track_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( args = SimpleNamespace(
format=options.get("format", DEFAULT_FORMAT), container=options.get("container"), # None -> auto-detect
transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO), audio_codec=options.get("audio_codec", "copy"),
drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO), video_codec=options.get("video_codec", "copy"),
drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS), subtitle_codec=options.get("subtitle_codec", "copy"),
number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS), video_quality=options.get("video_quality"),
replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS), drop_video=options.get("drop_video", False),
replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR), drop_subs=options.get("drop_subs", False),
bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS), number_tracks=options.get("number_tracks", False),
skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING), replace_bad_chars=options.get("replace_bad_chars", False),
output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE), replacement_char=options.get("replacement_char", "_"),
album=options.get("album", DEFAULT_ALBUM), bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
comment=options.get("comment", DEFAULT_COMMENT), skip_existing=options.get("skip_existing", False),
no_comment=options.get("no_comment", DEFAULT_NO_COMMENT), output_template=options.get("output_template", "%an-%tn.%ext"),
comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM), album=options.get("album", None),
merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS), comment=options.get("comment", None),
comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR), no_comment=options.get("no_comment", False),
delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web 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 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 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) split_audio(str(input_path), str(output_dir), tracks, args)