fix: address Immich import review feedback

Use Immich previews for photos, enforce the image download cap while streaming,
reuse design tokens and remove unused translations. Add regression coverage.

Signed-off-by: Askrosm <155456680+Askrosm@users.noreply.github.com>
This commit is contained in:
Askrosm committed 2026-10-20 22:05:42 +02:00
1 parent ae77105cd0
commit fb791b6752
9 files changed
+112 -19

No files matched your search

+13 -4
View File
@@ -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<Buffer | null> {
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<Buffer | null> {
private async readBytes(path: string, maxBytes: number, maxImageBytes = maxBytes): Promise<Buffer | null> {
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
}
+47
View File
@@ -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)
+4 -2
View File
@@ -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 :
+15
View File
@@ -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) {
+24 -1
View File
@@ -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)
@@ -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; }
@@ -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) }
-2
View File
@@ -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',
+2
View File
@@ -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);