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 { env } from '../config/env.js'
import { decryptImmichApiKey } from './immich-secret.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. // 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 : // 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> { async getAssetBytes(assetId: string, thumbnail: boolean, maxBytes: number, preview = false): Promise<Buffer | null> {
const variant = preview ? 'thumbnail?size=preview' : thumbnail ? 'thumbnail?size=thumbnail' : 'original' 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. // 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) 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) 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')) 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(() => {}) await response.body?.cancel().catch(() => {})
return null return null
} }
@@ -219,12 +221,19 @@ export class ImmichClient {
const reader = response.body.getReader() const reader = response.body.getReader()
const chunks: Buffer[] = [] const chunks: Buffer[] = []
let total = 0 let total = 0
let signature = Buffer.alloc(0)
try { try {
for (;;) { for (;;) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
total += value.byteLength 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(() => {}) await reader.cancel().catch(() => {})
return null return null
} }
+47
View File
@@ -153,6 +153,53 @@ describe('Souvenirs Immich', () => {
await app.close() 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 () => { it('importe les octets d’une vidéo et coupe un flux qui dépasse la limite', async () => {
const app = createApp() const app = createApp()
const token = await createUserToken(app) 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 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 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 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 des envois locaux : JPEG, PNG, WebP, GIF ; MP4, MOV, WebM. Pour les photos,
originaux HEIC/RAW ni des codecs vidéo n'est ajouté. 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 La clé Immich doit disposer de `asset.read`, `asset.view` et `asset.download`, en
plus des permissions utilisées pour les portraits. Contrat : 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() 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 // 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. // 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) { export async function fetchImmichConfig(treeId, token) {
+24 -1
View File
@@ -1,8 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchImmichAsset, listImmichAssets } from './immichApi.js' import { downloadImmichMemory, fetchImmichAsset, listImmichAssets } from './immichApi.js'
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals())
describe('Recherche et aperçus Immich', () => { 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 () => { it('encode le contexte et conserve le visage, la page et l’annulation', async () => {
const fetch = vi.fn().mockResolvedValue(new Response('{"assets":[],"hasNextPage":false}')) const fetch = vi.fn().mockResolvedValue(new Response('{"assets":[],"hasNextPage":false}'))
vi.stubGlobal('fetch', fetch) vi.stubGlobal('fetch', fetch)
@@ -1,5 +1,5 @@
/* Galerie Immich dans la DA terracotta des souvenirs. */ /* 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; } .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 { 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); } .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-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 { 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-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-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: rgba(42,38,34,.75); color: white; font: 500 11px var(--pz-sans); } .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 { 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-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 { 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-open:disabled, .iap-select:disabled, .iap-face-choice:disabled { cursor: default; }
.iap-select:disabled:not(.is-selected) { opacity: .45; } .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; } .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 { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { ArrowLeft, Check, ChevronLeft, ChevronRight, Image, Search, Sparkles, UserRound, Video, X, ZoomIn } from 'lucide-react' 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 { t } from '../i18n/index.js'
import PzBusy from './PzBusy.jsx' import PzBusy from './PzBusy.jsx'
import './ImmichAssetPicker.css' import './ImmichAssetPicker.css'
@@ -104,12 +104,9 @@ export default function ImmichAssetPicker({ treeId, token, onSelect, onClose })
const controller = new AbortController() const controller = new AbortController()
download.current = controller download.current = controller
try { 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 (controller.signal.aborted) return
if (!(asset.type === 'IMAGE' ? blob.type.startsWith('image/') : blob.type.startsWith('video/'))) { onSelect(file)
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 }))
if (!controller.signal.aborted) setSelected((ids) => [...ids, asset.id]) if (!controller.signal.aborted) setSelected((ids) => [...ids, asset.id])
} catch (err) { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) } } catch (err) { if (!controller.signal.aborted) setError(getImmichErrorMessage(err, t)) }
finally { if (!controller.signal.aborted) setBusy(false) } 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.', 'Essayez une autre description ou un autre visage.': 'Try another description or face.',
'Pages des souvenirs Immich': 'Immich memories pages', 'Pages des souvenirs Immich': 'Immich memories pages',
'Page {page}': 'Page {page}', 'Page {page}': 'Page {page}',
'{n} souvenir(s) sélectionné(s)': '{n} selected memory/memories',
'1 souvenir sélectionné': '1 memory selected', '1 souvenir sélectionné': '1 memory selected',
'{n} souvenirs sélectionnés': '{n} memories selected', '{n} souvenirs sélectionnés': '{n} memories selected',
'Photo : 5 Mo · Vidéo : 20 Mo': 'Photo: 5 MB · Video: 20 MB', '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).', '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.', 'Média introuvable dans Immich.': 'Media not found in Immich.',
'Format de média non pris en charge.': 'Unsupported media format.', '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', 'Type': 'Type',
'Photos': 'Photos', 'Photos': 'Photos',
'Vidéos': 'Videos', 'Vidéos': 'Videos',
+2
View File
@@ -25,6 +25,8 @@
--pz-success-bg: #e6f2e4; --pz-success-bg: #e6f2e4;
--pz-shadow: 0 1px 2px rgba(42, 38, 34, .05), 0 8px 24px rgba(42, 38, 34, .07); --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-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-serif: 'Newsreader', Georgia, 'Times New Roman', serif;
--pz-sans: 'DM Sans', system-ui, -apple-system, sans-serif; --pz-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
--pz-ease: cubic-bezier(.23, 1, .32, 1); --pz-ease: cubic-bezier(.23, 1, .32, 1);