rev 4
This commit is contained in:
@@ -26,36 +26,36 @@ async def get_current_user(
|
||||
payload = decode_token(token)
|
||||
except ExpiredSignatureError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Токен истёк"
|
||||
) from e
|
||||
except InvalidTokenError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен"
|
||||
) from e
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен"
|
||||
)
|
||||
|
||||
sub = payload.get("sub")
|
||||
if not sub:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен"
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = uuid.UUID(str(sub))
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен"
|
||||
) from e
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные учётные данные"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -98,6 +98,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# SaluteSpeech
|
||||
salute_auth_key: str = Field(default="", alias="SALUTE_AUTH_KEY")
|
||||
salute_ssl_verify: bool = Field(default=True, alias="SALUTE_SSL_VERIFY")
|
||||
salute_ca_cert_path: Path | None = Field(
|
||||
default=None, alias="SALUTE_CA_CERT_PATH"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
@@ -42,8 +43,10 @@ MAX_MB_SIZE = 1024
|
||||
async def upload_file(
|
||||
file: UploadFile = FastAPIFile(...),
|
||||
folder: str = Form(default=""),
|
||||
project_id: uuid.UUID | None = Form(default=None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
storage: StorageService = Depends(get_storage),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FileInfoResponse:
|
||||
# Validate max file size (matches old behavior).
|
||||
file.file.seek(0, 2)
|
||||
@@ -54,11 +57,18 @@ async def upload_file(
|
||||
if size_bytes > max_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds the maximum limit of {MAX_MB_SIZE} MB.",
|
||||
detail=f"Размер файла превышает допустимый лимит в {MAX_MB_SIZE} МБ.",
|
||||
)
|
||||
|
||||
user_folder = get_user_folder(current_user)
|
||||
resolved_folder = f"{user_folder}/{folder}" if folder else f"{user_folder}/user_upload"
|
||||
inferred_project_id = project_id
|
||||
if inferred_project_id is None and folder.startswith("projects/"):
|
||||
project_token = folder.removeprefix("projects/").split("/", 1)[0]
|
||||
try:
|
||||
inferred_project_id = uuid.UUID(project_token)
|
||||
except ValueError:
|
||||
inferred_project_id = None
|
||||
|
||||
key = await storage.upload_fileobj(
|
||||
fileobj=file.file,
|
||||
@@ -68,8 +78,23 @@ async def upload_file(
|
||||
content_type=file.content_type,
|
||||
)
|
||||
|
||||
service = FileService(db)
|
||||
file_entry = await service.create_file(
|
||||
requester=current_user,
|
||||
data=FileCreate(
|
||||
project_id=inferred_project_id,
|
||||
original_filename=file.filename or "upload.bin",
|
||||
path=key,
|
||||
storage_backend=get_settings().storage_backend.upper(),
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=size_bytes,
|
||||
file_format=Path(file.filename or "upload.bin").suffix.lstrip(".") or None,
|
||||
is_uploaded=True,
|
||||
),
|
||||
)
|
||||
info = await storage.get_file_info(key)
|
||||
return FileInfoResponse(
|
||||
file_id=file_entry.id,
|
||||
file_path=info.file_path,
|
||||
file_url=info.file_url,
|
||||
file_size=info.file_size,
|
||||
@@ -82,17 +107,24 @@ async def get_file_info(
|
||||
file_path: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
storage: StorageService = Depends(get_storage),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> FileInfoResponse:
|
||||
if not current_user.is_staff:
|
||||
user_prefix = f"{get_user_folder(current_user)}/"
|
||||
if not file_path.startswith(user_prefix):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
if not await storage.exists(file_path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
service = FileService(db)
|
||||
file = await service.get_file_by_path(file_path)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
info = await storage.get_file_info(file_path)
|
||||
return FileInfoResponse(
|
||||
file_id=file.id,
|
||||
file_path=info.file_path,
|
||||
file_url=info.file_url,
|
||||
file_size=info.file_size,
|
||||
@@ -110,7 +142,7 @@ async def get_local_file(
|
||||
settings = get_settings()
|
||||
full_path = (settings.local_storage_dir / file_path).resolve()
|
||||
if not full_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
return FileResponse(full_path)
|
||||
|
||||
@@ -145,14 +177,42 @@ async def retrieve_file_entry(
|
||||
service = FileService(db)
|
||||
file = await service.get_file(file_id)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return FileRead.model_validate(file)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/resolve/", response_model=FileInfoResponse)
|
||||
async def resolve_file_entry(
|
||||
file_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
storage: StorageService = Depends(get_storage),
|
||||
) -> FileInfoResponse:
|
||||
service = FileService(db)
|
||||
file = await service.get_file(file_id)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
if not await storage.exists(file.path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
info = await storage.get_file_info(file.path)
|
||||
return FileInfoResponse(
|
||||
file_id=file.id,
|
||||
file_path=file.path,
|
||||
file_url=info.file_url,
|
||||
file_size=info.file_size,
|
||||
filename=file.original_filename or info.filename,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/files/{file_id}/", response_model=FileRead)
|
||||
async def patch_file_entry(
|
||||
file_id: uuid.UUID,
|
||||
@@ -163,10 +223,10 @@ async def patch_file_entry(
|
||||
service = FileService(db)
|
||||
file = await service.get_file(file_id)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
file = await service.update_file(file, body)
|
||||
return FileRead.model_validate(file)
|
||||
@@ -181,10 +241,10 @@ async def delete_file_entry(
|
||||
service = FileService(db)
|
||||
file = await service.get_file(file_id)
|
||||
if file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
await service.delete_file(file)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -51,6 +51,7 @@ class FileUpdate(Schema):
|
||||
|
||||
|
||||
class FileInfoResponse(Schema):
|
||||
file_id: UUID
|
||||
file_path: str
|
||||
file_url: str
|
||||
file_size: int | None = None
|
||||
|
||||
@@ -22,6 +22,9 @@ class FileService:
|
||||
async def get_file(self, file_id: uuid.UUID) -> File | None:
|
||||
return await self._repo.get_by_id(file_id)
|
||||
|
||||
async def get_file_by_path(self, path: str) -> File | None:
|
||||
return await self._repo.get_by_path(path)
|
||||
|
||||
async def create_file(self, *, requester: User, data: FileCreate) -> File:
|
||||
return await self._repo.create(requester=requester, data=data)
|
||||
|
||||
|
||||
@@ -53,10 +53,10 @@ async def retrieve_job_endpoint(
|
||||
service = JobService(db)
|
||||
job = await service.get_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and job.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return JobRead.model_validate(job)
|
||||
|
||||
@@ -71,10 +71,10 @@ async def patch_job_endpoint(
|
||||
service = JobService(db)
|
||||
job = await service.get_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and job.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
if body.status == "CANCELLED":
|
||||
task_service = TaskService(db)
|
||||
@@ -94,10 +94,10 @@ async def delete_job_endpoint(
|
||||
service = JobService(db)
|
||||
job = await service.get_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and job.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
await service.deactivate_job(job)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -136,7 +136,7 @@ async def retrieve_event_endpoint(
|
||||
service = JobService(db)
|
||||
event = await service.get_job_event(event_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
return JobEventRead.model_validate(event)
|
||||
|
||||
@@ -152,7 +152,7 @@ async def patch_event_endpoint(
|
||||
service = JobService(db)
|
||||
event = await service.get_job_event(event_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
event = await service.update_job_event(event, body)
|
||||
return JobEventRead.model_validate(event)
|
||||
@@ -168,7 +168,7 @@ async def delete_event_endpoint(
|
||||
service = JobService(db)
|
||||
event = await service.get_job_event(event_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
await service.deactivate_job_event(event)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -157,10 +157,10 @@ async def retrieve_mediafile(
|
||||
repo = MediaFileRepository(db)
|
||||
media_file = await repo.get_by_id(media_file_id)
|
||||
if media_file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and media_file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return MediaFileRead.model_validate(media_file)
|
||||
|
||||
@@ -175,10 +175,10 @@ async def patch_mediafile(
|
||||
repo = MediaFileRepository(db)
|
||||
media_file = await repo.get_by_id(media_file_id)
|
||||
if media_file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and media_file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
media_file = await repo.update(media_file, body)
|
||||
return MediaFileRead.model_validate(media_file)
|
||||
@@ -193,10 +193,10 @@ async def delete_mediafile(
|
||||
repo = MediaFileRepository(db)
|
||||
media_file = await repo.get_by_id(media_file_id)
|
||||
if media_file is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and media_file.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
await repo.mark_deleted(media_file)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -237,7 +237,7 @@ async def retrieve_artifact_mediafile(
|
||||
repo = ArtifactRepository(db)
|
||||
artifact = await repo.get_by_id(artifact_id)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
return ArtifactMediaFileRead.model_validate(artifact)
|
||||
|
||||
@@ -253,7 +253,7 @@ async def patch_artifact_mediafile(
|
||||
repo = ArtifactRepository(db)
|
||||
artifact = await repo.get_by_id(artifact_id)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
artifact = await repo.update(artifact, body)
|
||||
return ArtifactMediaFileRead.model_validate(artifact)
|
||||
@@ -269,7 +269,7 @@ async def delete_artifact_mediafile(
|
||||
repo = ArtifactRepository(db)
|
||||
artifact = await repo.get_by_id(artifact_id)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
await repo.mark_deleted(artifact)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
+227
-74
@@ -19,6 +19,9 @@ FRAME_WIDTH_PX = 128
|
||||
FRAME_FPS = 1
|
||||
FRAME_JPEG_QUALITY = 5
|
||||
FRAMES_META_FILENAME = "meta.json"
|
||||
FFMPEG_PROGRESS_DIVISOR = 1_000_000.0
|
||||
|
||||
MediaProgressCallback = Callable[[str, float | None], None]
|
||||
|
||||
|
||||
def get_frames_folder(user_folder: str, file_key: str) -> str:
|
||||
@@ -55,6 +58,123 @@ async def probe_media(storage: StorageService, *, file_key: str) -> MediaProbeSc
|
||||
tmp.cleanup()
|
||||
|
||||
|
||||
def _parse_ffmpeg_timecode_seconds(value: str) -> float | None:
|
||||
parts = value.strip().split(":")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
hours = int(parts[0])
|
||||
minutes = int(parts[1])
|
||||
seconds = float(parts[2])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
|
||||
def _get_ffmpeg_output_time_seconds(progress_snapshot: dict[str, str]) -> float | None:
|
||||
timecode = progress_snapshot.get("out_time")
|
||||
if timecode:
|
||||
parsed = _parse_ffmpeg_timecode_seconds(timecode)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
for key in ("out_time_us", "out_time_ms"):
|
||||
raw_value = progress_snapshot.get(key)
|
||||
if raw_value is None:
|
||||
continue
|
||||
try:
|
||||
return max(float(raw_value), 0.0) / FFMPEG_PROGRESS_DIVISOR
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_ffmpeg_out_time_ms(progress_snapshot: dict[str, str]) -> float | None:
|
||||
seconds = _get_ffmpeg_output_time_seconds(progress_snapshot)
|
||||
if seconds is None:
|
||||
return None
|
||||
return seconds * 1000.0
|
||||
|
||||
|
||||
async def _probe_local_duration_seconds(input_path: str) -> float | None:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
input_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = json.loads(stdout.decode())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
duration = raw.get("format", {}).get("duration")
|
||||
if duration is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = float(duration)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
async def _forward_ffmpeg_progress(
|
||||
stdout: asyncio.StreamReader | None,
|
||||
*,
|
||||
duration_seconds: float | None,
|
||||
on_progress: MediaProgressCallback | None,
|
||||
progress_stage: str,
|
||||
) -> None:
|
||||
if stdout is None:
|
||||
return
|
||||
|
||||
snapshot: dict[str, str] = {}
|
||||
last_pct = -1.0
|
||||
|
||||
while True:
|
||||
line = await stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
decoded = line.decode(errors="ignore").strip()
|
||||
if "=" not in decoded:
|
||||
continue
|
||||
|
||||
key, value = decoded.split("=", 1)
|
||||
snapshot[key] = value
|
||||
|
||||
if key != "progress":
|
||||
continue
|
||||
|
||||
if (
|
||||
on_progress is not None
|
||||
and duration_seconds is not None
|
||||
and duration_seconds > 0
|
||||
):
|
||||
output_time_seconds = _get_ffmpeg_output_time_seconds(snapshot)
|
||||
if output_time_seconds is not None:
|
||||
pct = min(max((output_time_seconds / duration_seconds) * 100.0, 0.0), 100.0)
|
||||
if pct > last_pct:
|
||||
last_pct = pct
|
||||
on_progress(progress_stage, pct)
|
||||
|
||||
snapshot = {}
|
||||
|
||||
|
||||
def _compute_non_silent_segments(
|
||||
*,
|
||||
local_audio_path: str,
|
||||
@@ -83,6 +203,54 @@ def _compute_non_silent_segments(
|
||||
return segments
|
||||
|
||||
|
||||
def _build_trim_concat_filter(segments: list[tuple[int, int]]) -> str:
|
||||
parts: list[str] = []
|
||||
concat_inputs: list[str] = []
|
||||
|
||||
for index, (start_ms, end_ms) in enumerate(segments):
|
||||
start_s = start_ms / 1000.0
|
||||
end_s = end_ms / 1000.0
|
||||
video_label = f"v{index}"
|
||||
audio_label = f"a{index}"
|
||||
|
||||
parts.append(
|
||||
f"[0:v:0]trim=start={start_s:.3f}:end={end_s:.3f},setpts=PTS-STARTPTS[{video_label}]"
|
||||
)
|
||||
parts.append(
|
||||
f"[0:a:0]atrim=start={start_s:.3f}:end={end_s:.3f},asetpts=PTS-STARTPTS[{audio_label}]"
|
||||
)
|
||||
concat_inputs.append(f"[{video_label}][{audio_label}]")
|
||||
|
||||
return ";".join(parts + ["".join(concat_inputs) + f"concat=n={len(segments)}:v=1:a=1[v][a]"])
|
||||
|
||||
|
||||
def _build_trim_concat_command(
|
||||
*,
|
||||
input_path: str,
|
||||
out_path: str,
|
||||
segments: list[tuple[int, int]],
|
||||
) -> list[str]:
|
||||
return [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-filter_complex",
|
||||
_build_trim_concat_filter(segments),
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-preset",
|
||||
"medium",
|
||||
out_path,
|
||||
]
|
||||
|
||||
|
||||
async def detect_silence(
|
||||
storage: StorageService,
|
||||
*,
|
||||
@@ -137,6 +305,7 @@ async def apply_silence_cuts(
|
||||
out_folder: str,
|
||||
cuts: list[dict],
|
||||
output_name: str | None = None,
|
||||
on_progress: MediaProgressCallback | None = None,
|
||||
) -> FileInfo:
|
||||
"""Apply explicit cut regions to a media file, concatenating the non-cut parts."""
|
||||
input_tmp = await storage.download_to_temp(file_key)
|
||||
@@ -165,59 +334,47 @@ async def apply_silence_cuts(
|
||||
if not segments:
|
||||
return await storage.get_file_info(file_key)
|
||||
|
||||
output_duration_seconds = sum(end - start for start, end in segments) / 1000.0
|
||||
|
||||
with NamedTemporaryFile(
|
||||
suffix=path.splitext(file_key)[1] or ".mp4", delete=False
|
||||
) as out:
|
||||
out_path = out.name
|
||||
|
||||
try:
|
||||
cmd: list[str] = ["ffmpeg"]
|
||||
for start_ms, end_ms in segments:
|
||||
start_s = start_ms / 1000.0
|
||||
duration_s = (end_ms - start_ms) / 1000.0
|
||||
cmd.extend(
|
||||
[
|
||||
"-ss",
|
||||
f"{start_s:.3f}",
|
||||
"-t",
|
||||
f"{duration_s:.3f}",
|
||||
"-y",
|
||||
"-i",
|
||||
input_tmp.path,
|
||||
]
|
||||
)
|
||||
if on_progress is not None:
|
||||
on_progress("applying_cuts", 0.0)
|
||||
|
||||
seg_count = len(segments)
|
||||
parts = [f"[{i}:v:0][{i}:a:0]" for i in range(seg_count)]
|
||||
filter_complex = "".join(parts) + f"concat=n={seg_count}:v=1:a=1[v][a]"
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-preset",
|
||||
"medium",
|
||||
out_path,
|
||||
]
|
||||
cmd = _build_trim_concat_command(
|
||||
input_path=input_tmp.path,
|
||||
out_path=out_path,
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
progress_task = asyncio.create_task(
|
||||
_forward_ffmpeg_progress(
|
||||
proc.stdout,
|
||||
duration_seconds=output_duration_seconds,
|
||||
on_progress=on_progress,
|
||||
progress_stage="applying_cuts",
|
||||
)
|
||||
)
|
||||
stderr_task = asyncio.create_task(
|
||||
proc.stderr.read() if proc.stderr is not None else asyncio.sleep(0, result=b"")
|
||||
)
|
||||
await asyncio.gather(progress_task, stderr_task)
|
||||
await proc.wait()
|
||||
stderr = stderr_task.result()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
|
||||
|
||||
base_name = output_name or path.basename(file_key)
|
||||
output_key = path.join(out_folder or "", "silent", base_name)
|
||||
if on_progress is not None:
|
||||
on_progress("uploading", None)
|
||||
with open(out_path, "rb") as out_file:
|
||||
_ = await storage.upload_fileobj(
|
||||
fileobj=out_file,
|
||||
@@ -267,42 +424,10 @@ async def remove_silence(
|
||||
out_path = out.name
|
||||
|
||||
try:
|
||||
cmd: list[str] = ["ffmpeg"]
|
||||
for start_ms, end_ms in segments:
|
||||
start_s = start_ms / 1000.0
|
||||
duration_s = (end_ms - start_ms) / 1000.0
|
||||
cmd.extend(
|
||||
[
|
||||
"-ss",
|
||||
f"{start_s:.3f}",
|
||||
"-t",
|
||||
f"{duration_s:.3f}",
|
||||
"-y",
|
||||
"-i",
|
||||
input_tmp.path,
|
||||
]
|
||||
)
|
||||
|
||||
seg_count = len(segments)
|
||||
parts = [f"[{i}:v:0][{i}:a:0]" for i in range(seg_count)]
|
||||
filter_complex = "".join(parts) + f"concat=n={seg_count}:v=1:a=1[v][a]"
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"[a]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-preset",
|
||||
"medium",
|
||||
out_path,
|
||||
]
|
||||
cmd = _build_trim_concat_command(
|
||||
input_path=input_tmp.path,
|
||||
out_path=out_path,
|
||||
segments=segments,
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -333,21 +458,34 @@ async def remove_silence(
|
||||
|
||||
|
||||
async def convert_to_mp4(
|
||||
storage: StorageService, *, file_key: str, out_folder: str
|
||||
storage: StorageService,
|
||||
*,
|
||||
file_key: str,
|
||||
out_folder: str,
|
||||
on_progress: MediaProgressCallback | None = None,
|
||||
) -> FileInfo:
|
||||
input_tmp = await storage.download_to_temp(file_key)
|
||||
|
||||
try:
|
||||
filename_without_ext = path.splitext(path.basename(file_key))[0]
|
||||
mp4_filename = f"Конвертированое видео {filename_without_ext}.mp4"
|
||||
duration_seconds = await _probe_local_duration_seconds(input_tmp.path)
|
||||
|
||||
with NamedTemporaryFile(suffix=".mp4", delete=False) as out:
|
||||
out_path = out.name
|
||||
|
||||
try:
|
||||
if on_progress is not None:
|
||||
on_progress("converting", 0.0)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-nostdin",
|
||||
"-v",
|
||||
"error",
|
||||
"-progress",
|
||||
"pipe:1",
|
||||
"-i",
|
||||
input_tmp.path,
|
||||
"-c:v",
|
||||
@@ -364,11 +502,26 @@ async def convert_to_mp4(
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
progress_task = asyncio.create_task(
|
||||
_forward_ffmpeg_progress(
|
||||
proc.stdout,
|
||||
duration_seconds=duration_seconds,
|
||||
on_progress=on_progress,
|
||||
progress_stage="converting",
|
||||
)
|
||||
)
|
||||
stderr_task = asyncio.create_task(
|
||||
proc.stderr.read() if proc.stderr is not None else asyncio.sleep(0, result=b"")
|
||||
)
|
||||
await asyncio.gather(progress_task, stderr_task)
|
||||
await proc.wait()
|
||||
stderr = stderr_task.result()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {stderr.decode(errors='ignore')}")
|
||||
|
||||
output_key = path.join(out_folder or "", "converted", mp4_filename)
|
||||
if on_progress is not None:
|
||||
on_progress("uploading", None)
|
||||
with open(out_path, "rb") as out_file:
|
||||
_ = await storage.upload_fileobj(
|
||||
fileobj=out_file,
|
||||
|
||||
@@ -80,7 +80,7 @@ async def mark_notification_read(
|
||||
repo = NotificationRepository(db)
|
||||
found = await repo.mark_read(notification_id, current_user.id)
|
||||
if not found:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
|
||||
@router.post("/read-all/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
@@ -30,13 +29,6 @@ JOB_TYPE_LABELS: dict[str, str] = {
|
||||
"CAPTIONS_GENERATE": "Генерация субтитров",
|
||||
}
|
||||
|
||||
STATUS_TITLES: dict[str, str] = {
|
||||
"RUNNING": "Задача запущена",
|
||||
"DONE": "Задача завершена",
|
||||
"FAILED": "Ошибка выполнения",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConnectionManager — singleton for WebSocket pub/sub via Redis
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -113,14 +105,13 @@ class NotificationService:
|
||||
# Only persist notifications on status changes (not progress-only updates)
|
||||
notification_id: uuid.UUID | None = None
|
||||
if notification_type is not None:
|
||||
title = STATUS_TITLES.get(event.status or "", job_type_label)
|
||||
notification = await self._repo.create(
|
||||
NotificationCreate(
|
||||
user_id=user_id,
|
||||
job_id=job.id,
|
||||
project_id=job.project_id,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
title=job_type_label,
|
||||
message=event.error_message or event.current_message,
|
||||
payload={
|
||||
"job_type": job.job_type,
|
||||
@@ -139,8 +130,8 @@ class NotificationService:
|
||||
job_id=job.id,
|
||||
project_id=job.project_id,
|
||||
job_type=job.job_type,
|
||||
status=event.status or job.status,
|
||||
progress_pct=event.progress_pct or job.project_pct,
|
||||
status=event.status if event.status is not None else job.status,
|
||||
progress_pct=job.project_pct if event.progress_pct is None else event.progress_pct,
|
||||
message=event.error_message or event.current_message or job.current_message,
|
||||
title=job_type_label,
|
||||
created_at=now,
|
||||
|
||||
@@ -50,10 +50,10 @@ async def retrieve_project(
|
||||
service = ProjectService(db)
|
||||
project = await service.get_project(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return ProjectRead.model_validate(project)
|
||||
|
||||
@@ -68,10 +68,10 @@ async def patch_project(
|
||||
service = ProjectService(db)
|
||||
project = await service.get_project(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
project = await service.update_project(project, body)
|
||||
return ProjectRead.model_validate(project)
|
||||
@@ -86,10 +86,10 @@ async def delete_project(
|
||||
service = ProjectService(db)
|
||||
project = await service.get_project(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
await service.deactivate_project(project)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.db.session import get_db
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["System"])
|
||||
|
||||
@@ -8,3 +12,16 @@ router = APIRouter(prefix="/api", tags=["System"])
|
||||
@router.get("/ping/")
|
||||
async def ping() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/health/")
|
||||
async def health(db: AsyncSession = Depends(get_db)) -> dict[str, str]:
|
||||
"""Health check for Docker/K8s probes. Verifies DB connectivity."""
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
db_status = "connected"
|
||||
except Exception:
|
||||
db_status = "disconnected"
|
||||
|
||||
status = "ok" if db_status == "connected" else "degraded"
|
||||
return {"status": status, "database": db_status}
|
||||
|
||||
@@ -173,11 +173,11 @@ async def get_task_status(
|
||||
|
||||
if job is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Job not found"
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена"
|
||||
)
|
||||
|
||||
if not current_user.is_staff and job.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return TaskStatusResponse(
|
||||
job_id=job.id,
|
||||
|
||||
+192
-12
@@ -96,6 +96,10 @@ MESSAGE_COMPLETED = "Завершено"
|
||||
MESSAGE_PROBING_MEDIA = "Анализ медиафайла"
|
||||
MESSAGE_PROCESSING = "Обработка"
|
||||
MESSAGE_CONVERTING = "Конвертация"
|
||||
MESSAGE_PREPARING_FILE = "Подготовка файла"
|
||||
MESSAGE_CONVERTING_VIDEO = "Конвертация видео"
|
||||
MESSAGE_UPLOADING_RESULT = "Загрузка результата"
|
||||
MESSAGE_SAVING_RESULT = "Сохранение результата"
|
||||
MESSAGE_RENDERING_CAPTIONS = "Рендеринг субтитров"
|
||||
MESSAGE_CANCELLED = "Отменено пользователем"
|
||||
MESSAGE_EXTRACTING_FRAMES = "Извлечение кадров"
|
||||
@@ -106,6 +110,14 @@ PROGRESS_COMPLETE = 100.0
|
||||
PROGRESS_MEDIA_PROBE = 50.0
|
||||
PROGRESS_SILENCE_REMOVE = 30.0
|
||||
PROGRESS_MEDIA_CONVERT = 30.0
|
||||
PROGRESS_MEDIA_CONVERT_PREPARING = 5.0
|
||||
PROGRESS_MEDIA_CONVERT_START = 10.0
|
||||
PROGRESS_MEDIA_CONVERT_END = 95.0
|
||||
PROGRESS_MEDIA_CONVERT_SAVING = 99.0
|
||||
PROGRESS_SILENCE_APPLY_PREPARING = 5.0
|
||||
PROGRESS_SILENCE_APPLY_START = 10.0
|
||||
PROGRESS_SILENCE_APPLY_END = 95.0
|
||||
PROGRESS_SILENCE_APPLY_SAVING = 99.0
|
||||
PROGRESS_TRANSCRIPTION_START = 20.0
|
||||
PROGRESS_TRANSCRIPTION_END = 95.0
|
||||
PROGRESS_CAPTIONS = 30.0
|
||||
@@ -119,6 +131,7 @@ MESSAGE_DETECTING_SILENCE = "Обнаружение тишины"
|
||||
MESSAGE_APPLYING_CUTS = "Применение вырезок"
|
||||
|
||||
PROGRESS_THROTTLE_SECONDS = 3.0
|
||||
PROGRESS_CONVERT_THROTTLE_SECONDS = 1.0
|
||||
|
||||
ACTIVE_JOB_STATUSES = (JOB_STATUS_PENDING, JOB_STATUS_RUNNING)
|
||||
DRAMATIQ_BROKER_REF_SEPARATOR = ":"
|
||||
@@ -481,20 +494,56 @@ def silence_apply_actor(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
status=JOB_STATUS_RUNNING,
|
||||
current_message=MESSAGE_STARTING,
|
||||
current_message=MESSAGE_PREPARING_FILE,
|
||||
progress_pct=PROGRESS_SILENCE_APPLY_PREPARING,
|
||||
started_at=_utc_now(),
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
storage = _get_storage_service()
|
||||
_send_webhook_event(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
current_message=MESSAGE_APPLYING_CUTS,
|
||||
progress_pct=PROGRESS_SILENCE_APPLY,
|
||||
),
|
||||
)
|
||||
last_report_time = 0.0
|
||||
last_progress = PROGRESS_SILENCE_APPLY_PREPARING
|
||||
|
||||
def _emit_silence_apply_progress(stage: str, pct: float | None) -> None:
|
||||
nonlocal last_report_time, last_progress
|
||||
|
||||
if stage == "applying_cuts":
|
||||
raw_pct = min(max(pct or 0.0, 0.0), 100.0)
|
||||
if raw_pct >= 100.0:
|
||||
return
|
||||
mapped = PROGRESS_SILENCE_APPLY_START + (raw_pct / 100.0) * (
|
||||
PROGRESS_SILENCE_APPLY_END - PROGRESS_SILENCE_APPLY_START
|
||||
)
|
||||
message = MESSAGE_APPLYING_CUTS
|
||||
force = raw_pct == 0.0
|
||||
elif stage == "uploading":
|
||||
mapped = PROGRESS_SILENCE_APPLY_END
|
||||
message = MESSAGE_UPLOADING_RESULT
|
||||
force = True
|
||||
else:
|
||||
return
|
||||
|
||||
mapped = round(mapped, 1)
|
||||
now = time.monotonic()
|
||||
if not force:
|
||||
if mapped <= last_progress:
|
||||
return
|
||||
if mapped - last_progress < 1.0:
|
||||
return
|
||||
if now - last_report_time < PROGRESS_CONVERT_THROTTLE_SECONDS:
|
||||
return
|
||||
|
||||
last_report_time = now
|
||||
last_progress = max(last_progress, mapped)
|
||||
_send_webhook_event(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
current_message=message,
|
||||
progress_pct=last_progress,
|
||||
),
|
||||
)
|
||||
|
||||
result = _run_async(
|
||||
apply_silence_cuts(
|
||||
storage,
|
||||
@@ -502,8 +551,16 @@ def silence_apply_actor(
|
||||
out_folder=out_folder,
|
||||
cuts=cuts,
|
||||
output_name=output_name,
|
||||
on_progress=_emit_silence_apply_progress,
|
||||
)
|
||||
)
|
||||
_send_webhook_event(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
current_message=MESSAGE_SAVING_RESULT,
|
||||
progress_pct=PROGRESS_SILENCE_APPLY_SAVING,
|
||||
),
|
||||
)
|
||||
_send_webhook_event(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
@@ -554,7 +611,8 @@ def media_convert_actor(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
status=JOB_STATUS_RUNNING,
|
||||
current_message=MESSAGE_STARTING,
|
||||
current_message=MESSAGE_PREPARING_FILE,
|
||||
progress_pct=PROGRESS_MEDIA_CONVERT_PREPARING,
|
||||
started_at=_utc_now(),
|
||||
),
|
||||
)
|
||||
@@ -564,14 +622,63 @@ def media_convert_actor(
|
||||
raise ValueError(f"Неподдерживаемый формат: {output_format}")
|
||||
|
||||
storage = _get_storage_service()
|
||||
last_report_time = 0.0
|
||||
last_progress = PROGRESS_MEDIA_CONVERT_PREPARING
|
||||
|
||||
def _emit_convert_progress(stage: str, pct: float | None) -> None:
|
||||
nonlocal last_report_time, last_progress
|
||||
|
||||
if stage == "converting":
|
||||
raw_pct = min(max(pct or 0.0, 0.0), 100.0)
|
||||
if raw_pct >= 100.0:
|
||||
return
|
||||
mapped = PROGRESS_MEDIA_CONVERT_START + (raw_pct / 100.0) * (
|
||||
PROGRESS_MEDIA_CONVERT_END - PROGRESS_MEDIA_CONVERT_START
|
||||
)
|
||||
message = MESSAGE_CONVERTING_VIDEO
|
||||
force = raw_pct == 0.0
|
||||
elif stage == "uploading":
|
||||
mapped = PROGRESS_MEDIA_CONVERT_END
|
||||
message = MESSAGE_UPLOADING_RESULT
|
||||
force = True
|
||||
else:
|
||||
return
|
||||
|
||||
mapped = round(mapped, 1)
|
||||
now = time.monotonic()
|
||||
if not force:
|
||||
if mapped <= last_progress:
|
||||
return
|
||||
if mapped - last_progress < 1.0:
|
||||
return
|
||||
if now - last_report_time < PROGRESS_CONVERT_THROTTLE_SECONDS:
|
||||
return
|
||||
|
||||
last_report_time = now
|
||||
last_progress = max(last_progress, mapped)
|
||||
_send_webhook_event(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
current_message=message,
|
||||
progress_pct=last_progress,
|
||||
),
|
||||
)
|
||||
|
||||
result = _run_async(
|
||||
convert_to_mp4(
|
||||
storage,
|
||||
file_key=file_key,
|
||||
out_folder=out_folder,
|
||||
on_progress=_emit_convert_progress,
|
||||
)
|
||||
)
|
||||
_send_webhook_event(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
current_message=MESSAGE_CONVERTING,
|
||||
progress_pct=PROGRESS_MEDIA_CONVERT,
|
||||
current_message=MESSAGE_SAVING_RESULT,
|
||||
progress_pct=PROGRESS_MEDIA_CONVERT_SAVING,
|
||||
),
|
||||
)
|
||||
result = _run_async(convert_to_mp4(storage, file_key=file_key, out_folder=out_folder))
|
||||
_send_webhook_event(
|
||||
webhook_url,
|
||||
TaskWebhookEvent(
|
||||
@@ -1213,6 +1320,12 @@ class TaskService:
|
||||
except Exception:
|
||||
logger.exception("Failed to save convert artifacts for job %s", job_id)
|
||||
|
||||
if job.job_type == JOB_TYPE_SILENCE_APPLY and event.status == JOB_STATUS_DONE:
|
||||
try:
|
||||
await self._save_silence_apply_artifacts(job)
|
||||
except Exception:
|
||||
logger.exception("Failed to save silence apply artifacts for job %s", job_id)
|
||||
|
||||
if job.job_type == JOB_TYPE_CAPTIONS_GENERATE and event.status == JOB_STATUS_DONE:
|
||||
try:
|
||||
await self._save_captions_artifacts(job)
|
||||
@@ -1427,8 +1540,75 @@ class TaskService:
|
||||
),
|
||||
)
|
||||
|
||||
updated_output = dict(output_data)
|
||||
updated_output["file_id"] = str(converted_file.id)
|
||||
await self._job_repo.update(job, JobUpdate(output_data=updated_output))
|
||||
|
||||
logger.info("Saved convert artifacts for job %s", job.id)
|
||||
|
||||
async def _save_silence_apply_artifacts(self, job: Job) -> None:
|
||||
"""Create File and ArtifactMediaFile records for silence-applied video."""
|
||||
input_data = job.input_data or {}
|
||||
output_data = job.output_data or {}
|
||||
|
||||
file_key: str = input_data["file_key"]
|
||||
project_id: uuid.UUID | None = (
|
||||
uuid.UUID(input_data["project_id"]) if input_data.get("project_id") else None
|
||||
)
|
||||
|
||||
file_path: str = output_data["file_path"]
|
||||
file_size: int = output_data.get("file_size", 0)
|
||||
|
||||
user_repo = UserRepository(self._session)
|
||||
user = await user_repo.get_by_id(job.user_id) # type: ignore[arg-type]
|
||||
if user is None:
|
||||
logger.warning(
|
||||
"User %s not found, skipping silence apply artifact save", job.user_id
|
||||
)
|
||||
return
|
||||
|
||||
file_repo = FileRepository(self._session)
|
||||
source_file = await file_repo.get_by_path(file_key)
|
||||
if source_file is not None:
|
||||
stem = Path(source_file.original_filename).stem
|
||||
else:
|
||||
stem = Path(file_key).stem
|
||||
processed_filename = f"Видео без тишины {stem}.mp4"
|
||||
|
||||
processed_file = await file_repo.create(
|
||||
requester=user,
|
||||
data=FileCreate(
|
||||
project_id=project_id,
|
||||
original_filename=processed_filename,
|
||||
path=file_path,
|
||||
storage_backend="S3",
|
||||
mime_type="video/mp4",
|
||||
size_bytes=file_size,
|
||||
file_format="mp4",
|
||||
is_uploaded=True,
|
||||
),
|
||||
)
|
||||
|
||||
artifact_repo = ArtifactRepository(self._session)
|
||||
await artifact_repo.create(
|
||||
data=ArtifactMediaFileCreate(
|
||||
project_id=project_id,
|
||||
file_id=processed_file.id,
|
||||
media_file_id=None,
|
||||
artifact_type="SILENCE_REMOVED_VIDEO",
|
||||
),
|
||||
)
|
||||
|
||||
updated_output = dict(output_data)
|
||||
updated_output["file_id"] = str(processed_file.id)
|
||||
await self._job_repo.update(job, JobUpdate(output_data=updated_output))
|
||||
|
||||
logger.info(
|
||||
"Saved silence apply artifacts for job %s (file_id=%s)",
|
||||
job.id,
|
||||
processed_file.id,
|
||||
)
|
||||
|
||||
async def _save_captions_artifacts(self, job: Job) -> None:
|
||||
"""Create File and ArtifactMediaFile records for captioned video."""
|
||||
input_data = job.input_data or {}
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -79,6 +80,13 @@ ERROR_SALUTE_UPLOAD_FAILED = "Ошибка загрузки файла в Salute
|
||||
ERROR_SALUTE_TASK_FAILED = "Ошибка распознавания SaluteSpeech: {detail}"
|
||||
ERROR_SALUTE_TIMEOUT = "Превышено время ожидания распознавания SaluteSpeech"
|
||||
ERROR_SALUTE_UNSUPPORTED_FORMAT = "Неподдерживаемый формат аудио для SaluteSpeech: {ext}"
|
||||
ERROR_SALUTE_AUTH_KEY_MISSING = "Не задан SALUTE_AUTH_KEY для авторизации SaluteSpeech"
|
||||
ERROR_SALUTE_SSL_FAILED = (
|
||||
"SSL ошибка при обращении к SaluteSpeech: {detail}. "
|
||||
"Если используется корпоративный или локальный сертификат, "
|
||||
"укажите путь в SALUTE_CA_CERT_PATH. "
|
||||
"Для локальной отладки можно отключить проверку через SALUTE_SSL_VERIFY=false."
|
||||
)
|
||||
|
||||
_salute_token_lock = threading.Lock()
|
||||
_salute_token: str | None = None
|
||||
@@ -487,6 +495,27 @@ def _parse_salute_time(s: str) -> float:
|
||||
return float(s.rstrip("s"))
|
||||
|
||||
|
||||
def _build_salute_ssl_context() -> ssl.SSLContext:
|
||||
"""Build SSL context for SaluteSpeech using system trust plus optional custom CA."""
|
||||
settings = get_settings()
|
||||
if not settings.salute_ssl_verify:
|
||||
return ssl._create_unverified_context()
|
||||
|
||||
ssl_context = ssl.create_default_context()
|
||||
if settings.salute_ca_cert_path is not None:
|
||||
ssl_context.load_verify_locations(cafile=str(settings.salute_ca_cert_path))
|
||||
return ssl_context
|
||||
|
||||
|
||||
def _get_salute_auth_header_value() -> str:
|
||||
"""Build Basic auth header for SaluteSpeech from settings."""
|
||||
settings = get_settings()
|
||||
auth_key = settings.salute_auth_key.strip()
|
||||
if not auth_key:
|
||||
raise RuntimeError(ERROR_SALUTE_AUTH_KEY_MISSING)
|
||||
return f"Basic {auth_key}"
|
||||
|
||||
|
||||
def _get_salute_access_token(client: httpx.Client) -> str:
|
||||
"""Get or refresh SaluteSpeech OAuth token. Thread-safe."""
|
||||
global _salute_token, _salute_token_expires_at
|
||||
@@ -500,7 +529,7 @@ def _get_salute_access_token(client: httpx.Client) -> str:
|
||||
response = client.post(
|
||||
SALUTE_AUTH_URL,
|
||||
headers={
|
||||
"Authorization": f"Basic {settings.salute_auth_key}",
|
||||
"Authorization": _get_salute_auth_header_value(),
|
||||
"RqUID": str(uuid.uuid4()),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
@@ -708,8 +737,6 @@ def _salute_transcribe_sync(
|
||||
on_progress: ProgressCallback | None = None,
|
||||
) -> Document:
|
||||
"""Synchronous SaluteSpeech transcription (runs in Dramatiq worker thread)."""
|
||||
settings = get_settings()
|
||||
|
||||
ext = Path(local_file_path).suffix.lower()
|
||||
audio_encoding = SALUTE_ENCODING_MAP.get(ext)
|
||||
content_type = SALUTE_CONTENT_TYPE_MAP.get(ext)
|
||||
@@ -725,8 +752,8 @@ def _salute_transcribe_sync(
|
||||
salute_language = SALUTE_LANGUAGE_MAP.get(language or "", "ru-RU")
|
||||
|
||||
try:
|
||||
verify = str(settings.salute_ca_cert_path) if settings.salute_ca_cert_path else True
|
||||
with httpx.Client(verify=verify, timeout=30.0) as client:
|
||||
ssl_context = _build_salute_ssl_context()
|
||||
with httpx.Client(verify=ssl_context, timeout=30.0) as client:
|
||||
token = _get_salute_access_token(client)
|
||||
|
||||
with open(local_file_path, "rb") as f:
|
||||
@@ -748,6 +775,14 @@ def _salute_transcribe_sync(
|
||||
raw_result = _download_salute_result(client, token, response_file_id)
|
||||
|
||||
return _build_document_from_salute_result(raw_result, language=salute_language)
|
||||
except ssl.SSLError as exc:
|
||||
raise RuntimeError(ERROR_SALUTE_SSL_FAILED.format(detail=str(exc))) from exc
|
||||
except httpx.ConnectError as exc:
|
||||
if isinstance(exc.__cause__, ssl.SSLError):
|
||||
raise RuntimeError(
|
||||
ERROR_SALUTE_SSL_FAILED.format(detail=str(exc.__cause__))
|
||||
) from exc
|
||||
raise
|
||||
finally:
|
||||
if cleanup_fn is not None:
|
||||
cleanup_fn()
|
||||
|
||||
@@ -51,7 +51,7 @@ class UserRepository:
|
||||
await self._session.commit()
|
||||
except IntegrityError as e:
|
||||
await self._session.rollback()
|
||||
raise ValueError("User already exists or violates constraints") from e
|
||||
raise ValueError("Пользователь уже существует или нарушены ограничения") from e
|
||||
|
||||
await self._session.refresh(user)
|
||||
return user
|
||||
@@ -66,7 +66,7 @@ class UserRepository:
|
||||
await self._session.commit()
|
||||
except IntegrityError as e:
|
||||
await self._session.rollback()
|
||||
raise ValueError("Update violates constraints") from e
|
||||
raise ValueError("Обновление нарушает ограничения") from e
|
||||
|
||||
await self._session.refresh(user)
|
||||
return user
|
||||
|
||||
@@ -123,10 +123,10 @@ async def retrieve_user(
|
||||
service = UserService(db)
|
||||
user = await service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return await _resolve_avatar(user, storage)
|
||||
|
||||
@@ -142,10 +142,10 @@ async def patch_user(
|
||||
service = UserService(db)
|
||||
user = await service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
try:
|
||||
user = await service.update_user(user, body)
|
||||
@@ -164,10 +164,10 @@ async def delete_user(
|
||||
service = UserService(db)
|
||||
user = await service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
await service.deactivate_user(user)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -201,7 +201,7 @@ async def login(
|
||||
service = UserService(db)
|
||||
user = await service.authenticate(body.username, body.password)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверные учётные данные")
|
||||
|
||||
access, refresh = _issue_tokens(user)
|
||||
user_read = await _resolve_avatar(user, storage)
|
||||
@@ -226,5 +226,5 @@ async def refresh(body: TokenRefresh) -> TokenRefreshResponse:
|
||||
return TokenRefreshResponse(access=access, refresh=body.refresh)
|
||||
except (ExpiredSignatureError, InvalidTokenError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен обновления"
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ class UserService:
|
||||
async def create_user(self, data: UserCreate, *, requester: User | None) -> User:
|
||||
# Keep Django behavior: any authenticated user can create via this endpoint.
|
||||
if requester is None:
|
||||
raise ValueError("Authentication required")
|
||||
raise ValueError("Требуется авторизация")
|
||||
return await self._repo.create(data=data)
|
||||
|
||||
async def register_user(self, data: UserRegister) -> User:
|
||||
@@ -42,7 +42,7 @@ class UserService:
|
||||
|
||||
async def change_password(self, user: User, current_password: str, new_password: str) -> None:
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
raise ValueError("Current password is incorrect")
|
||||
raise ValueError("Текущий пароль неверен")
|
||||
new_hash = hash_password(new_password)
|
||||
await self._repo.update_password(user, new_hash)
|
||||
|
||||
|
||||
@@ -44,10 +44,10 @@ async def retrieve_webhook_endpoint(
|
||||
service = WebhookService(db)
|
||||
webhook = await service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and webhook.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
return WebhookRead.model_validate(webhook)
|
||||
|
||||
@@ -62,10 +62,10 @@ async def patch_webhook_endpoint(
|
||||
service = WebhookService(db)
|
||||
webhook = await service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and webhook.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
webhook = await service.update_webhook(webhook, body)
|
||||
return WebhookRead.model_validate(webhook)
|
||||
@@ -80,10 +80,10 @@ async def delete_webhook_endpoint(
|
||||
service = WebhookService(db)
|
||||
webhook = await service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
|
||||
|
||||
if not current_user.is_staff and webhook.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
|
||||
|
||||
await service.deactivate_webhook(webhook)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
Reference in New Issue
Block a user