Add MusicWorkshop application
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from .exception_service import ExceptionItemNotFoundError, ExceptionService
|
||||
from .matcher import Matcher
|
||||
from .metadata_status import probe_metadata_services
|
||||
from .preprocessor import Preprocessor
|
||||
from .repair_runner import RepairRunner, RepairService
|
||||
from .library_service import LibraryService, LibraryTrackNotFoundError
|
||||
from .scanner import Scanner
|
||||
from .schemas import (
|
||||
ConfigPayload,
|
||||
ConfigSaveResponse,
|
||||
ExceptionDetailPayload,
|
||||
ExceptionListResponse,
|
||||
ExceptionSummaryPayload,
|
||||
LibraryMoveToExceptionResponse,
|
||||
LibrarySummaryPayload,
|
||||
LibraryTracksPageResponse,
|
||||
MetadataStatusResponse,
|
||||
RepairExecuteRequest,
|
||||
RepairPreviewRequest,
|
||||
RepairPreviewResponse,
|
||||
RepairTaskCurrentResponse,
|
||||
RepairTaskRunResponse,
|
||||
TaskCurrentResponse,
|
||||
TaskDetailResponse,
|
||||
TaskHistoryListResponse,
|
||||
TaskItemsPageResponse,
|
||||
TaskLogsPageResponse,
|
||||
TaskRunResponse
|
||||
)
|
||||
from .storage import ConfigStore
|
||||
from .task_runner import TaskRunner
|
||||
from .task_store import TaskConflictError, TaskNotFoundError, TaskStore
|
||||
from .task_stream import TaskStreamManager
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_DB_PATH = BASE_DIR / 'data' / 'music_workshop.db'
|
||||
DB_PATH = Path(os.getenv('MUSIC_WORKSHOP_DB_PATH', DEFAULT_DB_PATH))
|
||||
store = ConfigStore(DB_PATH)
|
||||
task_store = TaskStore(DB_PATH)
|
||||
task_stream = TaskStreamManager()
|
||||
scanner = Scanner()
|
||||
preprocessor = Preprocessor()
|
||||
matcher = Matcher()
|
||||
library_service = LibraryService(task_store, preprocessor)
|
||||
exception_service = ExceptionService(task_store)
|
||||
task_runner = TaskRunner(task_store, scanner, preprocessor, task_stream, matcher)
|
||||
repair_service = RepairService(task_store, exception_service, matcher, preprocessor, task_stream)
|
||||
repair_runner = RepairRunner(task_store, task_stream, repair_service)
|
||||
repair_service.runner = repair_runner
|
||||
|
||||
app = FastAPI(title='Music Workshop API', version='0.1.0')
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:5173'
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*']
|
||||
)
|
||||
|
||||
|
||||
@app.on_event('startup')
|
||||
async def startup():
|
||||
task_stream.set_loop(asyncio.get_running_loop())
|
||||
task_store.fail_stale_active_tasks()
|
||||
|
||||
|
||||
@app.get('/api/health')
|
||||
def healthcheck():
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
@app.get('/api/config', response_model=ConfigPayload)
|
||||
def get_config():
|
||||
return store.get_config()
|
||||
|
||||
|
||||
@app.get('/api/config/metadata-status', response_model=MetadataStatusResponse)
|
||||
def get_config_metadata_status():
|
||||
config = store.get_config()
|
||||
return {'metadataStatus': probe_metadata_services(config['metadata'])}
|
||||
|
||||
|
||||
@app.put('/api/config', response_model=ConfigSaveResponse)
|
||||
def update_config(payload: ConfigPayload):
|
||||
saved_config = store.save_config(payload.model_dump())
|
||||
return {
|
||||
'config': saved_config,
|
||||
'metadataStatus': probe_metadata_services(saved_config['metadata'])
|
||||
}
|
||||
|
||||
|
||||
@app.post('/api/tasks/run', response_model=TaskRunResponse, status_code=202)
|
||||
def run_task():
|
||||
config_snapshot = store.get_config()
|
||||
|
||||
try:
|
||||
task = task_store.create_task_if_idle(config_snapshot)
|
||||
except TaskConflictError as error:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
'detail': 'Task already running',
|
||||
'task_id': error.active_task_id
|
||||
}
|
||||
)
|
||||
|
||||
threading.Thread(
|
||||
target=task_runner.start_task,
|
||||
args=(task['task_id'], config_snapshot),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
return {
|
||||
'task_id': task['task_id'],
|
||||
'status': task['status'],
|
||||
'current_stage': task['current_stage'],
|
||||
'stage_states': task['stage_states'],
|
||||
'started_at': task['started_at']
|
||||
}
|
||||
|
||||
|
||||
@app.get('/api/tasks/current', response_model=TaskCurrentResponse)
|
||||
def get_current_task():
|
||||
task = task_store.get_active_task() or task_store.get_latest_task()
|
||||
return {'task': task}
|
||||
|
||||
|
||||
@app.get('/api/repair-tasks/current', response_model=RepairTaskCurrentResponse)
|
||||
def get_current_repair_task():
|
||||
task = task_store.get_active_task('repair') or task_store.get_latest_task('repair')
|
||||
return {'task': task}
|
||||
|
||||
|
||||
@app.get('/api/tasks', response_model=TaskHistoryListResponse)
|
||||
def get_tasks(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=8, ge=1, le=200)
|
||||
):
|
||||
return task_store.list_task_history(page, page_size)
|
||||
|
||||
|
||||
@app.get('/api/tasks/{task_id}', response_model=TaskDetailResponse)
|
||||
def get_task(task_id: str):
|
||||
return {'task': task_store.get_task(task_id)}
|
||||
|
||||
|
||||
@app.get('/api/tasks/{task_id}/items', response_model=TaskItemsPageResponse)
|
||||
def get_task_items(
|
||||
task_id: str,
|
||||
scan_status: str | None = None,
|
||||
preprocess_status: str | None = None,
|
||||
match_status: str | None = None,
|
||||
dedupe_status: str | None = None,
|
||||
organize_status: str | None = None,
|
||||
active_only: bool = False,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200)
|
||||
):
|
||||
task_store.get_task(task_id)
|
||||
return task_store.list_task_items(
|
||||
task_id,
|
||||
scan_status,
|
||||
page,
|
||||
page_size,
|
||||
preprocess_status=preprocess_status,
|
||||
match_status=match_status,
|
||||
dedupe_status=dedupe_status,
|
||||
organize_status=organize_status,
|
||||
active_only=active_only
|
||||
)
|
||||
|
||||
|
||||
@app.get('/api/tasks/{task_id}/logs', response_model=TaskLogsPageResponse)
|
||||
def get_task_logs(
|
||||
task_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200)
|
||||
):
|
||||
task_store.get_task(task_id)
|
||||
return task_store.list_task_logs(task_id, page, page_size)
|
||||
|
||||
|
||||
@app.get('/api/library/summary', response_model=LibrarySummaryPayload)
|
||||
def get_library_summary():
|
||||
config = store.get_config()
|
||||
return library_service.get_summary(config.get('output') or '')
|
||||
|
||||
|
||||
@app.get('/api/library/tracks', response_model=LibraryTracksPageResponse)
|
||||
def get_library_tracks(
|
||||
q: str | None = None,
|
||||
artist: str | None = None,
|
||||
album: str | None = None,
|
||||
format: str | None = None,
|
||||
has_provenance: bool | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
sort_by: str = Query(default='organized_at'),
|
||||
sort_order: str = Query(default='desc')
|
||||
):
|
||||
config = store.get_config()
|
||||
return library_service.get_tracks_page(
|
||||
config.get('output') or '',
|
||||
q=q,
|
||||
artist=artist,
|
||||
album=album,
|
||||
format=format,
|
||||
has_provenance=has_provenance,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/library/tracks/{track_id}/move-to-exception', response_model=LibraryMoveToExceptionResponse)
|
||||
def move_library_track_to_exception(track_id: str):
|
||||
config = store.get_config()
|
||||
try:
|
||||
return library_service.move_track_to_exception(config, track_id)
|
||||
except TaskConflictError as error:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
'detail': 'Task already running',
|
||||
'task_id': error.active_task_id
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get('/api/exceptions/summary', response_model=ExceptionSummaryPayload)
|
||||
def get_exception_summary():
|
||||
return exception_service.get_summary()
|
||||
|
||||
|
||||
@app.get('/api/exceptions/items', response_model=ExceptionListResponse)
|
||||
def get_exception_items(
|
||||
type: str = Query(default='all'),
|
||||
resolution_status: str = Query(default='open'),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200)
|
||||
):
|
||||
return exception_service.get_items(type, page, page_size, resolution_status)
|
||||
|
||||
|
||||
@app.get('/api/exceptions/items/{exception_id}', response_model=ExceptionDetailPayload)
|
||||
def get_exception_item(exception_id: int):
|
||||
return exception_service.get_item(exception_id)
|
||||
|
||||
|
||||
@app.get('/api/exceptions/items/{exception_id}/audio')
|
||||
def get_exception_item_audio(exception_id: int, request: Request):
|
||||
audio_path = exception_service.resolve_audio_path(exception_id)
|
||||
file_size = audio_path.stat().st_size
|
||||
content_type = mimetypes.guess_type(audio_path.name)[0] or 'application/octet-stream'
|
||||
range_header = request.headers.get('range')
|
||||
|
||||
if not range_header:
|
||||
return StreamingResponse(
|
||||
_iter_file_range(audio_path, 0, file_size - 1),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': str(file_size)
|
||||
}
|
||||
)
|
||||
|
||||
start, end = _parse_range_header(range_header, file_size)
|
||||
content_length = end - start + 1
|
||||
return StreamingResponse(
|
||||
_iter_file_range(audio_path, start, end),
|
||||
status_code=206,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': str(content_length),
|
||||
'Content-Range': f'bytes {start}-{end}/{file_size}'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post('/api/exceptions/actions/preview', response_model=RepairPreviewResponse)
|
||||
def preview_exception_action(payload: RepairPreviewRequest):
|
||||
config_snapshot = store.get_config()
|
||||
return repair_service.preview(payload.model_dump(), config_snapshot)
|
||||
|
||||
|
||||
@app.post('/api/exceptions/actions/execute', response_model=RepairTaskRunResponse, status_code=202)
|
||||
def execute_exception_action(payload: RepairExecuteRequest):
|
||||
config_snapshot = store.get_config()
|
||||
try:
|
||||
task = repair_service.execute(payload.model_dump(), config_snapshot)
|
||||
except TaskConflictError as error:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={'detail': 'Repair task already running', 'task_id': error.active_task_id}
|
||||
)
|
||||
|
||||
threading.Thread(
|
||||
target=repair_runner.start_task,
|
||||
args=(task['task_id'], config_snapshot),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
return {
|
||||
'repair_task_id': task['task_id'],
|
||||
'status': task['status'],
|
||||
'current_stage': task['current_stage'],
|
||||
'stage_states': task['stage_states'],
|
||||
'started_at': task['started_at']
|
||||
}
|
||||
|
||||
|
||||
@app.get('/api/repair-tasks/{task_id}', response_model=TaskDetailResponse)
|
||||
def get_repair_task(task_id: str):
|
||||
task = task_store.get_task(task_id)
|
||||
if task.get('task_type') != 'repair':
|
||||
raise TaskNotFoundError(task_id)
|
||||
return {'task': task}
|
||||
|
||||
|
||||
@app.get('/api/repair-tasks/{task_id}/logs', response_model=TaskLogsPageResponse)
|
||||
def get_repair_task_logs(
|
||||
task_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200)
|
||||
):
|
||||
task = task_store.get_task(task_id)
|
||||
if task.get('task_type') != 'repair':
|
||||
raise TaskNotFoundError(task_id)
|
||||
return task_store.list_task_logs(task_id, page, page_size)
|
||||
|
||||
|
||||
@app.websocket('/api/tasks/{task_id}/stream')
|
||||
async def stream_task(task_id: str, websocket: WebSocket):
|
||||
try:
|
||||
snapshot = task_store.get_task_snapshot(task_id)
|
||||
except TaskNotFoundError:
|
||||
await websocket.accept()
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await task_stream.connect(task_id, websocket)
|
||||
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
'type': 'task.snapshot',
|
||||
'task_id': task_id,
|
||||
'stage': snapshot['task']['current_stage'],
|
||||
'timestamp': snapshot['task']['updated_at'],
|
||||
'data': snapshot
|
||||
}
|
||||
)
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
task_stream.disconnect(task_id, websocket)
|
||||
except Exception:
|
||||
task_stream.disconnect(task_id, websocket)
|
||||
raise
|
||||
|
||||
|
||||
@app.websocket('/api/repair-tasks/{task_id}/stream')
|
||||
async def stream_repair_task(task_id: str, websocket: WebSocket):
|
||||
await stream_task(task_id, websocket)
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
def value_error_handler(_, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={'detail': str(exc)})
|
||||
|
||||
|
||||
@app.exception_handler(TaskNotFoundError)
|
||||
def task_not_found_error_handler(_, exc: TaskNotFoundError):
|
||||
return JSONResponse(status_code=404, content={'detail': f'Task not found: {exc}'})
|
||||
|
||||
|
||||
@app.exception_handler(ExceptionItemNotFoundError)
|
||||
def exception_item_not_found_error_handler(_, exc: ExceptionItemNotFoundError):
|
||||
return JSONResponse(status_code=404, content={'detail': f'Exception item not found: {exc}'})
|
||||
|
||||
|
||||
@app.exception_handler(LibraryTrackNotFoundError)
|
||||
def library_track_not_found_error_handler(_, exc: LibraryTrackNotFoundError):
|
||||
return JSONResponse(status_code=404, content={'detail': f'Library track not found: {exc}'})
|
||||
|
||||
|
||||
@app.exception_handler(FileNotFoundError)
|
||||
def file_not_found_error_handler(_, exc: FileNotFoundError):
|
||||
return JSONResponse(status_code=404, content={'detail': str(exc)})
|
||||
|
||||
|
||||
def _parse_range_header(header_value: str, file_size: int) -> tuple[int, int]:
|
||||
if not header_value.startswith('bytes='):
|
||||
raise ValueError('Invalid Range header')
|
||||
|
||||
range_value = header_value[6:].strip()
|
||||
start_text, _, end_text = range_value.partition('-')
|
||||
if not start_text and not end_text:
|
||||
raise ValueError('Invalid Range header')
|
||||
|
||||
if start_text:
|
||||
start = int(start_text)
|
||||
end = int(end_text) if end_text else file_size - 1
|
||||
else:
|
||||
suffix_length = int(end_text)
|
||||
if suffix_length <= 0:
|
||||
raise ValueError('Invalid Range header')
|
||||
start = max(file_size - suffix_length, 0)
|
||||
end = file_size - 1
|
||||
|
||||
if start < 0 or end < start or start >= file_size:
|
||||
raise ValueError('Invalid Range header')
|
||||
|
||||
return start, min(end, file_size - 1)
|
||||
|
||||
|
||||
def _iter_file_range(file_path: Path, start: int, end: int, chunk_size: int = 64 * 1024):
|
||||
with file_path.open('rb') as file_handle:
|
||||
file_handle.seek(start)
|
||||
remaining = end - start + 1
|
||||
while remaining > 0:
|
||||
chunk = file_handle.read(min(chunk_size, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
Reference in New Issue
Block a user