diff --git a/backend/src/lib/immich.ts b/backend/src/lib/immich.ts index 911a9f2..9c9bb4f 100644 --- a/backend/src/lib/immich.ts +++ b/backend/src/lib/immich.ts @@ -4,6 +4,7 @@ import type { PrismaClient } from '@prisma/client' import { env } from '../config/env.js' import { decryptImmichApiKey } from './immich-secret.js' +import { sniffMimeType } from './media-files.js' // Client Immich : la reconnaissance des visages lit les personnes et leurs vignettes. // Contrat vérifié sur la spécification OpenAPI officielle d'Immich v3.1.0 : @@ -147,7 +148,7 @@ export class ImmichClient { async getAssetBytes(assetId: string, thumbnail: boolean, maxBytes: number, preview = false): Promise { const variant = preview ? 'thumbnail?size=preview' : thumbnail ? 'thumbnail?size=thumbnail' : 'original' - return this.readBytes(`/api/assets/${encodeURIComponent(assetId)}/${variant}`, maxBytes) + return this.readBytes(`/api/assets/${encodeURIComponent(assetId)}/${variant}`, maxBytes, 5 * 1024 * 1024) } // Toutes les personnes (sans les masquées), en suivant la pagination d'Immich. @@ -205,10 +206,11 @@ export class ImmichClient { return this.readBytes(`/api/people/${encodeURIComponent(personId)}/thumbnail`, maxBytes) } - private async readBytes(path: string, maxBytes: number): Promise { + private async readBytes(path: string, maxBytes: number, maxImageBytes = maxBytes): Promise { const response = await this.fetch(path) + let limit = response.headers.get('content-type')?.startsWith('image/') ? Math.min(maxBytes, maxImageBytes) : maxBytes const declared = Number(response.headers.get('content-length')) - if (Number.isFinite(declared) && declared > maxBytes) { + if (Number.isFinite(declared) && declared > limit) { await response.body?.cancel().catch(() => {}) return null } @@ -219,12 +221,19 @@ export class ImmichClient { const reader = response.body.getReader() const chunks: Buffer[] = [] let total = 0 + let signature = Buffer.alloc(0) try { for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength - if (total > maxBytes) { + // Ne pas se fier au Content-Type : même avec une signature répartie sur + // plusieurs chunks, une image est bornée à 5 Mo dès ses premiers octets. + if (signature.length < 12) { + signature = Buffer.concat([signature, Buffer.from(value.subarray(0, 12 - signature.length))]) + if (sniffMimeType(signature, 'video')?.startsWith('image/')) limit = Math.min(limit, maxImageBytes) + } + if (total > limit || (Number.isFinite(declared) && declared > limit)) { await reader.cancel().catch(() => {}) return null } diff --git a/backend/test/immich.test.ts b/backend/test/immich.test.ts index 3bf845f..d9b28b5 100644 --- a/backend/test/immich.test.ts +++ b/backend/test/immich.test.ts @@ -153,6 +153,53 @@ describe('Souvenirs Immich', () => { await app.close() }) + it.each(['preview', 'original'])('coupe une image à 5 Mo en flux sur %s, même déclarée vidéo', async (variant) => { + const app = createApp() + const token = await createUserToken(app) + const cancel = vi.fn() + let sent = 0 + const stream = new ReadableStream({ + pull(controller) { + // La signature JPEG arrive en trois chunks, sans Content-Length. + if (sent < 3) controller.enqueue(new Uint8Array(jpegBytes.subarray(sent, ++sent))) + else { sent++; controller.enqueue(new Uint8Array(1024 * 1024)) } + }, cancel, + }, { highWaterMark: 0 }) + fetchMock.mockResolvedValueOnce(new Response(stream, { headers: { 'content-type': 'video/mp4' } })) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/${variant}`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(413) + expect(cancel).toHaveBeenCalledOnce() + expect(sent).toBe(8) // 3 octets + 5 Mo : aucun chunk supplémentaire lu. + await app.close() + }) + + it('refuse une image annoncée au-dessus de 5 Mo avant toute lecture', async () => { + const app = createApp() + const token = await createUserToken(app) + const pull = vi.fn() + const cancel = vi.fn() + fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), { + headers: { 'content-type': 'image/jpeg', 'content-length': String(6 * 1024 * 1024) }, + })) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(413) + expect(pull).not.toHaveBeenCalled() + expect(cancel).toHaveBeenCalledOnce() + await app.close() + }) + + it('conserve la limite vidéo de 20 Mo au-delà de 5 Mo', async () => { + const app = createApp() + const token = await createUserToken(app) + const video = Buffer.alloc(6 * 1024 * 1024) + video.set(Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom')])) + fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video))) + const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + expect(response.rawPayload.equals(video)).toBe(true) + await app.close() + }) + it('importe les octets d’une vidéo et coupe un flux qui dépasse la limite', async () => { const app = createApp() const token = await createUserToken(app) diff --git a/docs/immich-memories.md b/docs/immich-memories.md index 9da198c..c84b85c 100644 --- a/docs/immich-memories.md +++ b/docs/immich-memories.md @@ -17,8 +17,10 @@ aussi disposer de `person.read` pour ce filtre et ses portraits. Les fichiers sont copiés dans Parenthèse : ils restent disponibles indépendamment d'Immich après l'enregistrement. Les limites existantes s'appliquent : 5 Mo par photo, 20 Mo par vidéo et quota de l'arbre. Les formats pris en charge sont ceux -des envois locaux : JPEG, PNG, WebP, GIF ; MP4, MOV, WebM. Aucun transcodage des -originaux HEIC/RAW ni des codecs vidéo n'est ajouté. +des envois locaux : JPEG, PNG, WebP, GIF ; MP4, MOV, WebM. Pour les photos, +Parenthèse copie la version preview préparée par Immich, ce qui permet aussi +d'importer les HEIC/RAW dont Immich a généré l'aperçu. Le nom du fichier suit +le format reçu. Les vidéos conservent leur original, sans transcodage ajouté. La clé Immich doit disposer de `asset.read`, `asset.view` et `asset.download`, en plus des permissions utilisées pour les portraits. Contrat : diff --git a/frontend/src/api/immichApi.js b/frontend/src/api/immichApi.js index 3f35f9b..3c01293 100644 --- a/frontend/src/api/immichApi.js +++ b/frontend/src/api/immichApi.js @@ -49,6 +49,21 @@ export async function fetchImmichAsset(treeId, assetId, token, variant = 'thumbn return response.blob() } +// Les photos utilisent la version préparée par Immich, y compris les HEIC/RAW. +// Le nom suit le format réellement reçu ; les vidéos gardent leur original. +export async function downloadImmichMemory(treeId, asset, token, signal) { + const photo = asset.type === 'IMAGE' + const blob = await fetchImmichAsset(treeId, asset.id, token, photo ? 'preview' : 'original', signal) + if (!(photo ? blob.type.startsWith('image/') : blob.type.startsWith('video/'))) { + const error = new Error('invalid_immich_media') + error.payload = { error: 'invalid_immich_media' } + throw error + } + const extension = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' }[blob.type] + const name = asset.name || asset.id + return new File([blob], photo && extension ? `${name.replace(/\.[^.]+$/, '')}${extension}` : name, { type: blob.type }) +} + // Connexion Immich de l'arbre : métadonnées sûres (jamais la clé, jamais l'adresse) et instances // autorisées par l'opérateur, nommées par leur libellé, dont le panneau d'administration fait un menu. export async function fetchImmichConfig(treeId, token) { diff --git a/frontend/src/api/immichApi.test.js b/frontend/src/api/immichApi.test.js index 782efaa..d53084f 100644 --- a/frontend/src/api/immichApi.test.js +++ b/frontend/src/api/immichApi.test.js @@ -1,8 +1,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fetchImmichAsset, listImmichAssets } from './immichApi.js' +import { downloadImmichMemory, fetchImmichAsset, listImmichAssets } from './immichApi.js' afterEach(() => vi.unstubAllGlobals()) describe('Recherche et aperçus Immich', () => { + it('importe une photo HEIC via la preview et adapte son nom au JPEG reçu', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('jpeg', { headers: { 'content-type': 'image/jpeg' } })) + vi.stubGlobal('fetch', fetch) + const controller = new AbortController() + const file = await downloadImmichMemory('tree', { id: 'photo', name: 'IMG_1234.HEIC', type: 'IMAGE' }, 'token', controller.signal) + expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/photo\/preview$/) + expect(fetch.mock.calls[0][1].signal).toBe(controller.signal) + expect(file.name).toBe('IMG_1234.jpg') + expect(file.type).toBe('image/jpeg') + expect(await file.text()).toBe('jpeg') + }) + it('importe la vidéo originale sans changer son nom', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('video', { headers: { 'content-type': 'video/quicktime' } })) + vi.stubGlobal('fetch', fetch) + const file = await downloadImmichMemory('tree', { id: 'video', name: 'film.MOV', type: 'VIDEO' }, 'token') + expect(fetch.mock.calls[0][0]).toMatch(/\/assets\/video\/original$/) + expect(file.name).toBe('film.MOV') + expect(file.type).toBe('video/quicktime') + }) + it('refuse une réponse vidéo pour une photo', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('heic', { headers: { 'content-type': 'video/mp4' } }))) + await expect(downloadImmichMemory('tree', { id: 'photo', type: 'IMAGE' }, 'token')).rejects.toMatchObject({ payload: { error: 'invalid_immich_media' } }) + }) it('encode le contexte et conserve le visage, la page et l’annulation', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"assets":[],"hasNextPage":false}')) vi.stubGlobal('fetch', fetch) diff --git a/frontend/src/components/ImmichAssetPicker.css b/frontend/src/components/ImmichAssetPicker.css index 8b58410..f676a37 100644 --- a/frontend/src/components/ImmichAssetPicker.css +++ b/frontend/src/components/ImmichAssetPicker.css @@ -1,5 +1,5 @@ /* Galerie Immich dans la DA terracotta des souvenirs. */ -.iap-overlay { z-index: 2400; } +.iap-overlay { z-index: var(--pz-z-modal-nested); } .pz-modal.iap { width: min(100%, 850px); padding: 0; gap: 0; overflow: auto; } .iap-head { padding: 28px 64px 22px 28px; border-bottom: 1px solid var(--pz-line-soft); } .iap-head .pz-eyebrow { display: flex; align-items: center; gap: 7px; color: var(--pz-a700); } @@ -30,12 +30,12 @@ .iap-image img[hidden] { display: none; } .iap-placeholder { display: grid; place-items: center; gap: 6px; color: var(--pz-faint); font: 400 11px var(--pz-sans); } .iap-placeholder .pz-busy { width: 40px; height: 24px; } -.iap-zoom { position: absolute; top: 9px; right: 9px; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: rgba(255,255,255,.92); color: var(--pz-ink-2); box-shadow: 0 2px 8px rgba(42,38,34,.1); } -.iap-video { position: absolute; left: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: rgba(42,38,34,.75); color: white; font: 500 11px var(--pz-sans); } +.iap-zoom { position: absolute; top: 9px; right: 9px; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--pz-surface); color: var(--pz-ink-2); box-shadow: var(--pz-shadow); } +.iap-video { position: absolute; left: 8px; bottom: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border-radius: 999px; background: var(--pz-media-badge-bg); color: var(--pz-surface); font: 500 11px var(--pz-sans); } .iap-asset-caption { display: flex; align-items: center; gap: 7px; padding: 8px 10px; } .iap-asset-caption > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 400 12px/1.4 var(--pz-sans); color: var(--pz-muted); } .iap-select { display: grid; place-items: center; width: 30px; height: 30px; flex: none; padding: 0; background: var(--pz-surface); color: var(--pz-faint); border: 1px solid var(--pz-line); border-radius: 50%; cursor: pointer; } -.iap-select.is-selected { background: var(--pz-a500); border-color: var(--pz-a500); color: white; } +.iap-select.is-selected { background: var(--pz-a500); border-color: var(--pz-a500); color: var(--pz-surface); } .iap-open:disabled, .iap-select:disabled, .iap-face-choice:disabled { cursor: default; } .iap-select:disabled:not(.is-selected) { opacity: .45; } .iap :is(.iap-open, .iap-select, .iap-face-choice, .pz-tab):focus-visible { outline: 2px solid var(--pz-a500); outline-offset: -3px; } diff --git a/frontend/src/components/ImmichAssetPicker.jsx b/frontend/src/components/ImmichAssetPicker.jsx index 293faea..accf56e 100644 --- a/frontend/src/components/ImmichAssetPicker.jsx +++ b/frontend/src/components/ImmichAssetPicker.jsx @@ -1,7 +1,7 @@ import { useEffect, useId, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { ArrowLeft, Check, ChevronLeft, ChevronRight, Image, Search, Sparkles, UserRound, Video, X, ZoomIn } from 'lucide-react' -import { fetchImmichAsset, fetchImmichThumbnail, getImmichErrorMessage, listImmichAssets, listImmichPeople } from '../api/immichApi.js' +import { downloadImmichMemory, fetchImmichAsset, fetchImmichThumbnail, getImmichErrorMessage, listImmichAssets, listImmichPeople } from '../api/immichApi.js' import { t } from '../i18n/index.js' import PzBusy from './PzBusy.jsx' import './ImmichAssetPicker.css' @@ -104,12 +104,9 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose }) const controller = new AbortController() download.current = controller try { - const blob = await fetchImmichAsset(treeId, asset.id, token, 'original', controller.signal) + const file = await downloadImmichMemory(treeId, asset, token, controller.signal) if (controller.signal.aborted) return - if (!(asset.type === 'IMAGE' ? blob.type.startsWith('image/') : blob.type.startsWith('video/'))) { - const error = new Error('invalid_immich_media'); error.payload = { error: 'invalid_immich_media' }; throw error - } - onSelect(new File([blob], asset.name || asset.id, { type: blob.type })) + onSelect(file) if (!controller.signal.aborted) setSelected((ids) => [...ids, asset.id]) } catch (err) { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) } finally { if (!controller.signal.aborted) setBusy(false) } diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 501c046..26b17d2 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -288,7 +288,6 @@ export default { 'Essayez une autre description ou un autre visage.': 'Try another description or face.', 'Pages des souvenirs Immich': 'Immich memories pages', 'Page {page}': 'Page {page}', - '{n} souvenir(s) sélectionné(s)': '{n} selected memory/memories', '1 souvenir sélectionné': '1 memory selected', '{n} souvenirs sélectionnés': '{n} memories selected', 'Photo : 5 Mo · Vidéo : 20 Mo': 'Photo: 5 MB · Video: 20 MB', @@ -297,7 +296,6 @@ export default { 'Fichier trop volumineux (photo : 5 Mo, vidéo : 20 Mo).': 'File too large (photo: 5 MB, video: 20 MB).', 'Média introuvable dans Immich.': 'Media not found in Immich.', 'Format de média non pris en charge.': 'Unsupported media format.', - 'Les médias sélectionnés seront copiés dans les souvenirs. Photo : 5 Mo maximum ; vidéo : 20 Mo maximum.': 'Selected media will be copied into memories. Photo: up to 5 MB; video: up to 20 MB.', 'Type': 'Type', 'Photos': 'Photos', 'Vidéos': 'Videos', diff --git a/frontend/src/styles/pz.css b/frontend/src/styles/pz.css index d62a4b2..b8cc8d9 100644 --- a/frontend/src/styles/pz.css +++ b/frontend/src/styles/pz.css @@ -25,6 +25,8 @@ --pz-success-bg: #e6f2e4; --pz-shadow: 0 1px 2px rgba(42, 38, 34, .05), 0 8px 24px rgba(42, 38, 34, .07); --pz-shadow-lg: 0 30px 80px rgba(42, 38, 34, .22), 0 2px 6px rgba(42, 38, 34, .06); + --pz-media-badge-bg: rgba(42, 38, 34, .75); + --pz-z-modal-nested: 2400; --pz-serif: 'Newsreader', Georgia, 'Times New Roman', serif; --pz-sans: 'DM Sans', system-ui, -apple-system, sans-serif; --pz-ease: cubic-bezier(.23, 1, .32, 1);