diff --git a/backend/Dockerfile b/backend/Dockerfile index 87e49db..209a072 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index b93360e..17769e5 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -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}") diff --git a/backend/app/services/media.py b/backend/app/services/media.py index c4dfb44..d905162 100644 --- a/backend/app/services/media.py +++ b/backend/app/services/media.py @@ -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: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 93f5204..19d1945 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,7 +4,7 @@ server { root /usr/share/nginx/html; index index.html; - client_max_body_size 15m; + client_max_body_size 200m; gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; diff --git a/frontend/package.json b/frontend/package.json index 19512e2..e4ad600 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "homehub-frontend", "private": true, - "version": "0.5.3", + "version": "0.5.4", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/notes.ts b/frontend/src/api/notes.ts index 8b0a667..4c998b8 100644 --- a/frontend/src/api/notes.ts +++ b/frontend/src/api/notes.ts @@ -2,7 +2,7 @@ export interface NoteAttachment { id: string file_path: string | null thumbnail_path: string | null - file_type: 'image' | 'audio' | null + file_type: 'image' | 'audio' | 'video' | null original_name: string | null created_at: string } @@ -34,6 +34,7 @@ export interface NoteFilters { tag?: string has_photo?: boolean has_audio?: boolean + has_video?: boolean has_gps?: boolean } @@ -46,6 +47,7 @@ export async function fetchNotes(filters: NoteFilters = {}): Promise { if (filters.tag) params.set('tag', filters.tag) if (filters.has_photo !== undefined) params.set('has_photo', String(filters.has_photo)) if (filters.has_audio !== undefined) params.set('has_audio', String(filters.has_audio)) + if (filters.has_video !== undefined) params.set('has_video', String(filters.has_video)) if (filters.has_gps !== undefined) params.set('has_gps', String(filters.has_gps)) const res = await fetch(`${BASE}/?${params}`) if (!res.ok) throw new Error('Erreur chargement notes') diff --git a/frontend/src/pages/NotesPage.tsx b/frontend/src/pages/NotesPage.tsx index 8510a1f..5bce26b 100644 --- a/frontend/src/pages/NotesPage.tsx +++ b/frontend/src/pages/NotesPage.tsx @@ -21,22 +21,25 @@ function formatDate(iso: string) { return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' }) } -function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt }: { +function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onAddVideo, onDeleteAtt }: { note: Note onEdit: () => void onDelete: () => void onAddPhoto: (file: File) => void onAddAudio: (file: File) => void + onAddVideo: (file: File) => void onDeleteAtt: (attId: string) => void }) { const photoRef = useRef(null) const audioRef = useRef(null) + const videoRef = useRef(null) const [recording, setRecording] = useState(false) const recorderRef = useRef(null) const chunksRef = useRef([]) const images = note.attachments.filter(a => a.file_type === 'image') const audios = note.attachments.filter(a => a.file_type === 'audio') + const videos = note.attachments.filter(a => a.file_type === 'video') async function startRecord() { try { @@ -116,6 +119,26 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt )} + {/* Vidéos */} + {videos.length > 0 && ( +
+ {videos.map(vid => ( +
+
+ ))} +
+ )} + {/* Méta */}
{formatDate(note.created_at)} @@ -125,7 +148,12 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt {note.tags.map(t => ( {t} ))} - {note.gps_lat != null && } + {images.length > 0 && } + {audios.length > 0 && } + {videos.length > 0 && } + {note.gps_lat != null && ( + + )}
{/* Actions */} @@ -135,24 +163,31 @@ function NoteCard({ note, onEdit, onDelete, onAddPhoto, onAddAudio, onDeleteAtt onClick={() => photoRef.current?.click()} title="Ajouter une photo" style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-3)', cursor: 'pointer', fontSize: 13 }} - >📷 + > { const f = e.target.files?.[0]; if (f) onAddAudio(f); e.target.value = '' }} /> + > + + { const f = e.target.files?.[0]; if (f) onAddVideo(f); e.target.value = '' }} /> +
+ > + >
) @@ -248,6 +283,15 @@ export default function NotesPage() { } } + async function handleAddVideo(noteId: string, file: File) { + try { + await addAttachment(noteId, file) + void load() + } catch { + setError('Erreur upload vidéo') + } + } + async function handleDeleteAtt(noteId: string, attId: string) { try { await deleteAttachment(noteId, attId) @@ -257,7 +301,7 @@ export default function NotesPage() { } } - const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_gps + const hasActiveFilters = filters.has_photo || filters.has_audio || filters.has_video || filters.has_gps return (
@@ -277,28 +321,36 @@ export default function NotesPage() { onChange={e => handleSearchChange(e.target.value)} /> {/* Filtres rapides */} - {(['📷 Photo', '🎤 Audio', '📍 GPS'] as const).map((label, i) => { - const key = ['has_photo', 'has_audio', 'has_gps'][i] as keyof NoteFilters + {([ + { key: 'has_photo', icon: 'fa-image', label: 'Photo' }, + { key: 'has_audio', icon: 'fa-microphone', label: 'Audio' }, + { key: 'has_video', icon: 'fa-video', label: 'Vidéo' }, + { key: 'has_gps', icon: 'fa-location-dot', label: 'GPS' }, + ] as const).map(({ key, icon, label }) => { const active = filters[key] === true return ( + > + + {label} + ) })} {hasActiveFilters && ( + > Filtres )}
@@ -345,6 +397,7 @@ export default function NotesPage() { onDelete={() => void handleDelete(note.id)} onAddPhoto={f => void handleAddPhoto(note.id, f)} onAddAudio={f => void handleAddAudio(note.id, f)} + onAddVideo={f => void handleAddVideo(note.id, f)} onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)} /> ))} @@ -365,6 +418,7 @@ export default function NotesPage() { onDelete={() => void handleDelete(note.id)} onAddPhoto={f => void handleAddPhoto(note.id, f)} onAddAudio={f => void handleAddAudio(note.id, f)} + onAddVideo={f => void handleAddVideo(note.id, f)} onDeleteAtt={attId => void handleDeleteAtt(note.id, attId)} /> ))}