mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
1 parent
ae77105cd0
commit
fb791b6752
9 files changed
+112
-19
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user