419 lines
15 KiB
Python
419 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import secrets
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..auth import require_session
|
|
from ..database import get_db
|
|
from ..models import AudioAsset, AudioClip, GameAssignment, UserSession
|
|
from ..schemas import (
|
|
AudioAssetImportCreate,
|
|
AudioAssetResponse,
|
|
AudioAssetUpdate,
|
|
AudioClipCreate,
|
|
AudioClipResponse,
|
|
AudioClipUpdate,
|
|
AudioClipReorder,
|
|
)
|
|
from ..storage import storage
|
|
|
|
router = APIRouter(prefix="/media", tags=["media"])
|
|
|
|
DEFAULT_CLIP_LENGTH_MS = 30_000
|
|
|
|
|
|
def clip_to_response(clip: AudioClip) -> AudioClipResponse:
|
|
normalized_url = f"/media/files/{clip.normalized_path}" if clip.normalized_path else None
|
|
waveform = storage.load_or_generate_waveform(clip.asset.storage_path)
|
|
return AudioClipResponse(
|
|
id=clip.id,
|
|
asset_id=clip.asset_id,
|
|
external_team_id=clip.asset.external_team_id,
|
|
owner_external_player_id=clip.asset.owner_external_player_id,
|
|
asset_title=clip.asset.title,
|
|
label=clip.label,
|
|
start_ms=clip.start_ms,
|
|
end_ms=clip.end_ms,
|
|
sort_order=clip.sort_order,
|
|
hidden=clip.hidden,
|
|
normalization_status=clip.normalization_status,
|
|
normalized_url=normalized_url,
|
|
waveform_duration_ms=waveform["duration_ms"] if waveform else None,
|
|
waveform_peaks=waveform["peaks"] if waveform else None,
|
|
created_at=clip.created_at,
|
|
)
|
|
|
|
|
|
def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_player_id: str | None = None) -> bool:
|
|
if session.is_admin or asset.uploaded_by_session_id == session.id:
|
|
return True
|
|
return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id
|
|
|
|
|
|
def next_clip_sort_order(db: Session, *, external_team_id: str, owner_external_player_id: str) -> int:
|
|
highest_sort_order = db.scalar(
|
|
select(func.max(AudioClip.sort_order))
|
|
.join(AudioClip.asset)
|
|
.where(
|
|
AudioAsset.external_team_id == external_team_id,
|
|
AudioAsset.owner_external_player_id == owner_external_player_id,
|
|
)
|
|
)
|
|
return highest_sort_order + 1 if highest_sort_order is not None else 0
|
|
|
|
|
|
def create_asset_with_default_clip(
|
|
*,
|
|
db: Session,
|
|
session: UserSession,
|
|
external_team_id: str,
|
|
owner_external_player_id: str,
|
|
title: str,
|
|
original_filename: str,
|
|
mime_type: str,
|
|
size_bytes: int,
|
|
storage_path: str,
|
|
) -> AudioAssetResponse:
|
|
asset = AudioAsset(
|
|
external_team_id=external_team_id,
|
|
owner_external_player_id=owner_external_player_id,
|
|
uploaded_by_session_id=session.id,
|
|
title=title,
|
|
original_filename=original_filename,
|
|
mime_type=mime_type,
|
|
size_bytes=size_bytes,
|
|
storage_path=storage_path,
|
|
)
|
|
db.add(asset)
|
|
db.flush()
|
|
|
|
clip = AudioClip(
|
|
asset_id=asset.id,
|
|
label=asset.title,
|
|
start_ms=0,
|
|
end_ms=DEFAULT_CLIP_LENGTH_MS,
|
|
sort_order=next_clip_sort_order(
|
|
db,
|
|
external_team_id=external_team_id,
|
|
owner_external_player_id=owner_external_player_id,
|
|
),
|
|
normalization_status="processing",
|
|
)
|
|
db.add(clip)
|
|
db.flush()
|
|
|
|
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
|
|
clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
|
|
clip.normalization_status = "ready"
|
|
storage.generate_waveform(asset.storage_path)
|
|
|
|
db.commit()
|
|
db.refresh(asset)
|
|
return AudioAssetResponse.model_validate(asset, from_attributes=True)
|
|
|
|
|
|
def download_media_to_storage(url: str) -> tuple[str, int, str, str]:
|
|
try:
|
|
from yt_dlp import YoutubeDL
|
|
from yt_dlp.utils import DownloadError
|
|
except ImportError as exc: # pragma: no cover - guarded by dependency install
|
|
raise HTTPException(status_code=500, detail="yt-dlp is not installed") from exc
|
|
|
|
storage_name = f"{secrets.token_hex(16)}.%(ext)s"
|
|
outtmpl = str(storage.uploads_dir / storage_name)
|
|
options = {
|
|
"format": "bestaudio/best",
|
|
"noplaylist": True,
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"outtmpl": outtmpl,
|
|
"restrictfilenames": True,
|
|
"extractor_args": {"youtube": {"player_client": ["android"]}},
|
|
}
|
|
node_path = shutil.which("node")
|
|
if node_path:
|
|
options["js_runtimes"] = {"node": {"path": node_path}}
|
|
|
|
try:
|
|
with YoutubeDL(options) as ydl:
|
|
info = ydl.extract_info(url, download=True)
|
|
downloaded_path = Path(ydl.prepare_filename(info))
|
|
except DownloadError as exc: # pragma: no cover - exercised via HTTP behavior
|
|
message = str(exc).strip() or "Could not download media from that URL"
|
|
raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc
|
|
except Exception as exc: # pragma: no cover - exercised via HTTP behavior
|
|
message = str(exc).strip() or "Could not download media from that URL"
|
|
raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc
|
|
|
|
if not downloaded_path.exists():
|
|
raise HTTPException(status_code=502, detail="Downloaded file was not created")
|
|
|
|
size_bytes = downloaded_path.stat().st_size
|
|
original_filename = downloaded_path.name
|
|
source_title = str(info.get("title") or downloaded_path.stem)
|
|
return str(downloaded_path.relative_to(storage.root)), size_bytes, original_filename, source_title
|
|
|
|
|
|
@router.post("/uploads", response_model=AudioAssetResponse)
|
|
async def upload_audio(
|
|
external_team_id: str = Form(...),
|
|
owner_external_player_id: str = Form(...),
|
|
title: str = Form(...),
|
|
file: UploadFile = File(...),
|
|
session: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> AudioAssetResponse:
|
|
extension = Path(file.filename or "upload.bin").suffix or ".bin"
|
|
storage_name = f"{secrets.token_hex(16)}{extension}"
|
|
relative_path, size = storage.save_upload(file, storage_name)
|
|
return create_asset_with_default_clip(
|
|
db=db,
|
|
session=session,
|
|
external_team_id=external_team_id,
|
|
owner_external_player_id=owner_external_player_id,
|
|
title=title,
|
|
original_filename=file.filename or storage_name,
|
|
mime_type=file.content_type or "application/octet-stream",
|
|
size_bytes=size,
|
|
storage_path=relative_path,
|
|
)
|
|
|
|
|
|
@router.post("/imports", response_model=AudioAssetResponse)
|
|
def import_audio(
|
|
payload: AudioAssetImportCreate,
|
|
session: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> AudioAssetResponse:
|
|
relative_path, size_bytes, original_filename, source_title = download_media_to_storage(payload.url)
|
|
title = payload.title.strip() if payload.title else ""
|
|
if not title:
|
|
title = source_title
|
|
|
|
return create_asset_with_default_clip(
|
|
db=db,
|
|
session=session,
|
|
external_team_id=payload.external_team_id,
|
|
owner_external_player_id=payload.owner_external_player_id,
|
|
title=title,
|
|
original_filename=original_filename,
|
|
mime_type="application/octet-stream",
|
|
size_bytes=size_bytes,
|
|
storage_path=relative_path,
|
|
)
|
|
|
|
|
|
@router.get("/assets", response_model=list[AudioAssetResponse])
|
|
def list_assets(
|
|
external_team_id: str,
|
|
owner_external_player_id: str | None = None,
|
|
_: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> list[AudioAssetResponse]:
|
|
query = select(AudioAsset).where(AudioAsset.external_team_id == external_team_id)
|
|
if owner_external_player_id:
|
|
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
|
|
assets = db.scalars(query.order_by(AudioAsset.created_at.desc())).all()
|
|
return [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets]
|
|
|
|
|
|
@router.delete("/assets/{asset_id}", status_code=204)
|
|
def delete_asset(
|
|
asset_id: int,
|
|
owner_external_player_id: str | None = None,
|
|
session: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> None:
|
|
asset = db.get(AudioAsset, asset_id)
|
|
if asset is None:
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
if not can_manage_asset(session, asset, owner_external_player_id):
|
|
raise HTTPException(status_code=403, detail="You can only delete your own uploads")
|
|
|
|
clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all()
|
|
clip_ids = [clip.id for clip in clips]
|
|
if clip_ids:
|
|
db.execute(delete(GameAssignment).where(GameAssignment.clip_id.in_(clip_ids)))
|
|
for clip in clips:
|
|
if clip.normalized_path:
|
|
storage.delete_relative_path(clip.normalized_path)
|
|
db.delete(clip)
|
|
|
|
storage.delete_relative_path(asset.storage_path)
|
|
db.delete(asset)
|
|
db.commit()
|
|
|
|
|
|
@router.patch("/assets/{asset_id}", response_model=AudioAssetResponse)
|
|
def update_asset(
|
|
asset_id: int,
|
|
payload: AudioAssetUpdate,
|
|
owner_external_player_id: str | None = None,
|
|
session: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> AudioAssetResponse:
|
|
asset = db.get(AudioAsset, asset_id)
|
|
if asset is None:
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
if not can_manage_asset(session, asset, owner_external_player_id):
|
|
raise HTTPException(status_code=403, detail="You can only update your own uploads")
|
|
|
|
title = payload.title.strip()
|
|
if not title:
|
|
raise HTTPException(status_code=422, detail="File name cannot be blank")
|
|
|
|
asset.title = title
|
|
db.commit()
|
|
db.refresh(asset)
|
|
return AudioAssetResponse.model_validate(asset, from_attributes=True)
|
|
|
|
|
|
@router.post("/clips", response_model=AudioClipResponse)
|
|
def create_clip(
|
|
payload: AudioClipCreate,
|
|
_: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> AudioClipResponse:
|
|
asset = db.get(AudioAsset, payload.asset_id)
|
|
if asset is None:
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
if asset.external_team_id != payload.external_team_id:
|
|
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
|
|
if asset.owner_external_player_id != payload.owner_external_player_id:
|
|
raise HTTPException(status_code=403, detail="You can only create clips for that player")
|
|
if payload.end_ms <= payload.start_ms:
|
|
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
|
|
|
|
clip = AudioClip(
|
|
asset_id=asset.id,
|
|
label=payload.label,
|
|
start_ms=payload.start_ms,
|
|
end_ms=payload.end_ms,
|
|
sort_order=next_clip_sort_order(
|
|
db,
|
|
external_team_id=payload.external_team_id,
|
|
owner_external_player_id=payload.owner_external_player_id,
|
|
),
|
|
normalization_status="processing",
|
|
)
|
|
db.add(clip)
|
|
db.flush()
|
|
|
|
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
|
|
clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
|
|
clip.normalization_status = "ready"
|
|
db.commit()
|
|
db.refresh(clip)
|
|
return clip_to_response(clip)
|
|
|
|
|
|
@router.patch("/clips/{clip_id}", response_model=AudioClipResponse)
|
|
def update_clip(
|
|
clip_id: int,
|
|
payload: AudioClipUpdate,
|
|
owner_external_player_id: str | None = None,
|
|
session: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> AudioClipResponse:
|
|
clip = db.get(AudioClip, clip_id)
|
|
if clip is None:
|
|
raise HTTPException(status_code=404, detail="Clip not found")
|
|
if not can_manage_asset(session, clip.asset, owner_external_player_id):
|
|
raise HTTPException(status_code=403, detail="You can only update clips from your own uploads")
|
|
if payload.end_ms <= payload.start_ms:
|
|
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
|
|
|
|
clip.label = payload.label or clip.label
|
|
clip.start_ms = payload.start_ms
|
|
clip.end_ms = payload.end_ms
|
|
if payload.sort_order is not None:
|
|
clip.sort_order = payload.sort_order
|
|
if payload.hidden is not None:
|
|
clip.hidden = payload.hidden
|
|
db.commit()
|
|
db.refresh(clip)
|
|
return clip_to_response(clip)
|
|
|
|
|
|
@router.delete("/clips/{clip_id}", status_code=204)
|
|
def delete_clip(
|
|
clip_id: int,
|
|
owner_external_player_id: str | None = None,
|
|
session: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> None:
|
|
clip = db.get(AudioClip, clip_id)
|
|
if clip is None:
|
|
raise HTTPException(status_code=404, detail="Clip not found")
|
|
if not can_manage_asset(session, clip.asset, owner_external_player_id):
|
|
raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads")
|
|
|
|
db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id))
|
|
if clip.normalized_path:
|
|
storage.delete_relative_path(clip.normalized_path)
|
|
db.delete(clip)
|
|
db.commit()
|
|
|
|
|
|
@router.get("/clips", response_model=list[AudioClipResponse])
|
|
def list_clips(
|
|
external_team_id: str,
|
|
owner_external_player_id: str | None = None,
|
|
include_hidden: bool = False,
|
|
_: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> list[AudioClipResponse]:
|
|
query = (
|
|
select(AudioClip)
|
|
.join(AudioClip.asset)
|
|
.where(AudioAsset.external_team_id == external_team_id)
|
|
.order_by(AudioClip.sort_order.asc(), AudioClip.created_at.desc())
|
|
)
|
|
if owner_external_player_id:
|
|
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
|
|
if not include_hidden:
|
|
query = query.where(AudioClip.hidden.is_(False))
|
|
clips = db.scalars(query).all()
|
|
return [clip_to_response(clip) for clip in clips]
|
|
|
|
|
|
@router.post("/clips/reorder", status_code=204)
|
|
def reorder_clips(
|
|
payload: AudioClipReorder,
|
|
session: UserSession = Depends(require_session),
|
|
db: Session = Depends(get_db),
|
|
) -> None:
|
|
if not session.is_admin and session.external_team_id != payload.external_team_id:
|
|
raise HTTPException(status_code=403, detail="You can only reorder clips for your selected team")
|
|
|
|
clips = db.scalars(
|
|
select(AudioClip)
|
|
.join(AudioClip.asset)
|
|
.where(
|
|
AudioAsset.external_team_id == payload.external_team_id,
|
|
AudioAsset.owner_external_player_id == payload.owner_external_player_id,
|
|
)
|
|
).all()
|
|
clips_by_id = {clip.id: clip for clip in clips}
|
|
if len(clips_by_id) != len(clips) or set(clips_by_id) != set(payload.clip_ids):
|
|
raise HTTPException(status_code=422, detail="Clip order must include every clip for that player")
|
|
|
|
for sort_order, clip_id in enumerate(payload.clip_ids):
|
|
clips_by_id[clip_id].sort_order = sort_order
|
|
|
|
db.commit()
|
|
|
|
|
|
@router.get("/files/{relative_path:path}")
|
|
def media_file(relative_path: str) -> FileResponse:
|
|
path = storage.absolute_path(relative_path)
|
|
if not path.exists():
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
return FileResponse(path)
|