feat(notes): support vidéo + transcodage audio AAC universel

Audio : ffmpeg transcode toute entrée (webm/ogg/m4a) vers AAC/m4a
au moment de l'upload → lecture Safari iOS garantie.

Vidéo : nouveau save_video(), webm transcodé en H.264/mp4, mp4/quicktime
stocké directement. Lecteur <video> inline dans NoteCard.

Frontend :
- Bouton vidéo (fa-video) dans les actions de chaque note
- Icônes fa-image / fa-microphone / fa-video / fa-location-dot dans la méta
- Filtres rapides : Photo / Audio / Vidéo / GPS (avec icônes fa)
- Boutons actions migrés vers icônes Font Awesome
- client_max_body_size nginx : 15m → 200m pour les vidéos

v0.5.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:31:05 +02:00
co-authored by Claude Sonnet 4.6
parent 11b5c6c92e
commit 6c9ebcaab7
7 changed files with 152 additions and 34 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc postgresql-client \
libpq-dev gcc postgresql-client ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
+9 -9
View File
@@ -9,7 +9,7 @@ from app.core.database import get_session
from app.core.redis import enqueue
from app.models.notes import NoteItem, NoteAttachment
from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES
from app.services.media import save_image, save_audio, save_video, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_PREFIXES, ALLOWED_VIDEO_TYPES
router = APIRouter()
@@ -21,6 +21,7 @@ async def list_notes(
tag: str | None = Query(default=None),
has_photo: bool | None = Query(default=None),
has_audio: bool | None = Query(default=None),
has_video: bool | None = Query(default=None),
has_gps: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
@@ -55,15 +56,11 @@ async def list_notes(
notes = result.scalars().all()
if has_photo is not None:
notes = [
n for n in notes
if has_photo == any(a.file_type == "image" for a in n.attachments)
]
notes = [n for n in notes if has_photo == any(a.file_type == "image" for a in n.attachments)]
if has_audio is not None:
notes = [
n for n in notes
if has_audio == any(a.file_type == "audio" for a in n.attachments)
]
notes = [n for n in notes if has_audio == any(a.file_type == "audio" for a in n.attachments)]
if has_video is not None:
notes = [n for n in notes if has_video == any(a.file_type == "video" for a in n.attachments)]
return notes
@@ -137,6 +134,9 @@ async def add_attachment(
elif ct in ALLOWED_AUDIO_PREFIXES:
media = await save_audio(file)
file_type = "audio"
elif ct in ALLOWED_VIDEO_TYPES:
media = await save_video(file)
file_type = "video"
else:
raise HTTPException(400, f"Type non supporté : {file.content_type}")
+68 -6
View File
@@ -1,3 +1,4 @@
import asyncio
import io
import uuid
from pathlib import Path
@@ -13,7 +14,8 @@ ALLOWED_IMAGE_TYPES = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml",
"image/heic", "image/heif",
}
ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a"}
ALLOWED_AUDIO_PREFIXES = {"audio/webm", "audio/mp4", "audio/ogg", "audio/x-m4a", "audio/aac"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/webm", "video/quicktime", "video/x-m4v", "video/3gpp"}
MAX_ORIG_SIZE = (500, 500)
@@ -48,7 +50,6 @@ async def save_image(file: UploadFile, context: str = "note") -> dict:
orig_path = orig_dir / f"{file_id}.webp"
img = Image.open(io.BytesIO(content)).convert("RGB")
# Redimensionne l'original à 500×500 max en conservant l'aspect ratio
img.thumbnail(MAX_ORIG_SIZE, Image.LANCZOS)
img.save(orig_path, "WEBP", quality=85)
@@ -78,18 +79,79 @@ async def save_audio(file: UploadFile) -> dict:
audio_dir = UPLOAD_DIR / "audio"
audio_dir.mkdir(parents=True, exist_ok=True)
ext = ".webm" if "webm" in (file.content_type or "") else ".m4a"
audio_path = audio_dir / f"{file_id}{ext}"
audio_path.write_bytes(await file.read())
raw_ext = ".ogg" if "ogg" in ct else (".webm" if "webm" in ct else ".m4a")
raw_path = audio_dir / f"{file_id}_raw{raw_ext}"
raw_path.write_bytes(await file.read())
# Transcode vers AAC/mp4 pour lecture universelle (Safari iOS, Chrome, Firefox)
out_path = audio_dir / f"{file_id}.m4a"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(raw_path),
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
if out_path.exists():
raw_path.unlink(missing_ok=True)
final_path = out_path
else:
# ffmpeg indisponible ou échec — conserver le fichier brut
final_path = audio_dir / f"{file_id}{raw_ext}"
raw_path.rename(final_path)
return {
"file_id": file_id,
"file_path": str(audio_path.relative_to(UPLOAD_DIR)),
"file_path": str(final_path.relative_to(UPLOAD_DIR)),
"thumbnail_path": None,
"file_type": "audio",
}
async def save_video(file: UploadFile) -> dict:
ct = (file.content_type or "").lower().split(";")[0].strip()
if ct not in ALLOWED_VIDEO_TYPES:
raise HTTPException(status_code=400, detail=f"Format vidéo non supporté : {file.content_type}")
file_id = str(uuid.uuid4())
video_dir = UPLOAD_DIR / "videos"
video_dir.mkdir(parents=True, exist_ok=True)
content = await file.read()
if "webm" in ct:
# Transcode webm → H.264/mp4 pour Safari iOS
raw_path = video_dir / f"{file_id}_raw.webm"
raw_path.write_bytes(content)
out_path = video_dir / f"{file_id}.mp4"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(raw_path),
"-c:v", "libx264", "-crf", "28", "-preset", "fast",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", str(out_path),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.communicate()
if out_path.exists():
raw_path.unlink(missing_ok=True)
final_path = out_path
else:
final_path = video_dir / f"{file_id}.webm"
raw_path.rename(final_path)
else:
# mp4/quicktime : déjà compatible, stockage direct
final_path = video_dir / f"{file_id}.mp4"
final_path.write_bytes(content)
return {
"file_id": file_id,
"file_path": str(final_path.relative_to(UPLOAD_DIR)),
"thumbnail_path": None,
"file_type": "video",
}
def delete_media(file_id: str, file_path: str, thumbnail_path: str | None = None) -> None:
(UPLOAD_DIR / file_path).unlink(missing_ok=True)
if thumbnail_path: