Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03fbb1189f | ||
|
|
21665ed024 | ||
|
|
111618bd7f | ||
|
|
9cb875ca79 | ||
|
|
c5b95becd2 |
@@ -0,0 +1,85 @@
|
|||||||
|
# 2026.08.29.03 — the todo board leaves, and a plugin can claim a file type
|
||||||
|
|
||||||
|
**No schema change and no dependency change.** Verified rather than assumed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only deploy/2026.08.29.02..deploy/2026.08.29.03 | grep -iE 'schema|/db/|databases' # nothing
|
||||||
|
git diff --stat deploy/2026.08.29.02..deploy/2026.08.29.03 -- package.json bun.lock # nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
|
||||||
|
Ten commits, all code.
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **`/todos` is gone from the platform.** It is now the `kanban` plugin, in the marketplace. The
|
||||||
|
`todos` permission, the route, the screen and the board were all removed.
|
||||||
|
- **A plugin can claim a file type.** A manifest may declare `fileTypes`; the file browser then offers
|
||||||
|
thumbnails and a full-size render for formats the platform itself cannot read. av-club claims
|
||||||
|
`heic`/`heif`, so on a host with av-club installed, iPhone photographs get pictures instead of file
|
||||||
|
icons and open in the viewer.
|
||||||
|
- Thumbnails retry rather than giving up, and list and grid ask for the same size.
|
||||||
|
- The directory picker moved into the platform so more than one plugin can use it.
|
||||||
|
|
||||||
|
## A behaviour change to expect, and it is deliberate
|
||||||
|
|
||||||
|
**`/todos` now 404s.** Anyone with it bookmarked gets nothing. The board is the `kanban` plugin, which
|
||||||
|
is in the marketplace and installed nowhere by default — that is the intended state, not an oversight.
|
||||||
|
|
||||||
|
`role_permissions` rows naming `todos` are now orphaned. Harmless: absence denies, and the registry no
|
||||||
|
longer has the key. **Do not clean them up as part of this rollout** — it is a data edit nobody asked
|
||||||
|
for, and a row naming a permission that does not exist grants nothing.
|
||||||
|
|
||||||
|
## Every host — the commands
|
||||||
|
|
||||||
|
The standard steps in `prompts/deploy-commander.md` are the whole rollout this time (dirty check,
|
||||||
|
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.03`, `bun run plugins:update`), plus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 restart officer
|
||||||
|
```
|
||||||
|
|
||||||
|
`officer` only — `plugins:update` has already restarted the plugin sidecars.
|
||||||
|
|
||||||
|
### Verify it came back
|
||||||
|
|
||||||
|
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||||
|
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
|
||||||
|
|
||||||
|
### And check the file browser still lists
|
||||||
|
|
||||||
|
This release touched the listing route, so one call per host is worth it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun -e "
|
||||||
|
const { sign } = await import('./src/servers/jwt');
|
||||||
|
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
|
||||||
|
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
|
||||||
|
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
|
||||||
|
process.exit(0)"
|
||||||
|
```
|
||||||
|
|
||||||
|
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
Surveyed 2026-08-29 while writing this.
|
||||||
|
|
||||||
|
| Host | Note |
|
||||||
|
| --- | --- |
|
||||||
|
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||||
|
| `alpha` | No av-club, so no HEIC support — expected, not a fault. |
|
||||||
|
| `fofos` | **The only host with av-club installed**, and already at this commit and verified: 6 HEICs thumbnail and one renders at full size. Bring it to the tag so its HEAD matches the fleet. |
|
||||||
|
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||||
|
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||||
|
| `production-pertento-bridge` | Port **9010**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
|
||||||
|
| `edge-pertento-bridge` | Did not answer the survey while this plan was written. It may simply have been busy; treat it as an ordinary target and report what you find. |
|
||||||
|
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||||
|
|
||||||
|
## What "done" looks like
|
||||||
|
|
||||||
|
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
|
||||||
|
`restart_time`, and the `ls` check's output.
|
||||||
|
|
||||||
|
Nothing in this release needs a database query, a backup or an install. If you find yourself running
|
||||||
|
one, stop — the plan is wrong and that is worth reporting rather than working around.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.08.29.02",
|
"version": "2026.08.29.03",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { snapshotPlugins } from '../../plugins/mount';
|
||||||
|
import { sidecarPort } from '../../sidecar-registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File types an installed plugin can show and the platform cannot.
|
||||||
|
*
|
||||||
|
* ── One place, so three consumers cannot disagree ──
|
||||||
|
*
|
||||||
|
* `ls` decides whether an entry promises a thumbnail, `/thumb` decides where to get one, and the viewer
|
||||||
|
* decides whether a full-size render exists. Those must be the same answer: a listing that promises a
|
||||||
|
* thumbnail nothing will produce is a request that 404s on every scroll, and a viewer that offers a
|
||||||
|
* render for a type nothing claims is a spinner that never resolves.
|
||||||
|
*
|
||||||
|
* ── Core does not know which plugin this is ──
|
||||||
|
*
|
||||||
|
* It reads `fileTypes` from whatever is installed AND ENABLED, and calls a path by name on that
|
||||||
|
* plugin's own sidecar. Nothing here imports plugin code and nothing names av-club. Disable the plugin
|
||||||
|
* and the claims vanish with it, which is what makes the file browser degrade to exactly what it was
|
||||||
|
* rather than to something broken.
|
||||||
|
*
|
||||||
|
* ── Why the sidecar directly, and not our own HTTP surface ──
|
||||||
|
*
|
||||||
|
* A loopback request back through `/api` would need a credential to satisfy the platform's own auth,
|
||||||
|
* and minting one so the server can call itself is a worse thing to have than this. `sidecarPort` is
|
||||||
|
* the port the plugin announced when it started; the proxy in front of it exists for browsers, and the
|
||||||
|
* server is already inside.
|
||||||
|
*
|
||||||
|
* ── `x-officer-user` is the whole authentication, and that is the sidecar's own design ──
|
||||||
|
*
|
||||||
|
* A plugin sidecar binds loopback and accepts that header from anything already on the machine — the
|
||||||
|
* platform is the thing that decides who may call, and by the time this runs the account gate and the
|
||||||
|
* permission gate have both passed. `sidecarFetch` below sends it because the proxy in front of the
|
||||||
|
* sidecar sends it, and a caller that skips it gets a 401 that looks like the plugin being broken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FileTypeClaim = {
|
||||||
|
plugin: string;
|
||||||
|
/** Absolute URL on the plugin's sidecar, or null when it did not offer that half. */
|
||||||
|
thumbnail: string | null;
|
||||||
|
render: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension → who claims it. Lowercase keys, no leading dot.
|
||||||
|
*
|
||||||
|
* Not cached: `snapshotPlugins` reads the database, and this is called on a listing that is already
|
||||||
|
* doing database work. A cache would need invalidating on install, uninstall, enable and disable —
|
||||||
|
* four events to get wrong in exchange for a lookup that is already cheap.
|
||||||
|
*/
|
||||||
|
export async function pluginFileTypes(): Promise<Map<string, FileTypeClaim>> {
|
||||||
|
const claims = new Map<string, FileTypeClaim>();
|
||||||
|
|
||||||
|
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||||
|
try {
|
||||||
|
({ states } = await snapshotPlugins());
|
||||||
|
} catch {
|
||||||
|
// The file browser still works. A database that cannot be read is a much louder problem elsewhere,
|
||||||
|
// and losing every thumbnail to it would only obscure that.
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const state of states) {
|
||||||
|
if (!state.install?.enabled) continue;
|
||||||
|
|
||||||
|
const declared = state.plugin.manifest?.fileTypes;
|
||||||
|
if (!declared?.extensions?.length) continue;
|
||||||
|
|
||||||
|
const port = sidecarPort(state.plugin.appName);
|
||||||
|
// Claimed but not running. Skipped rather than recorded, so `ls` does not promise a thumbnail that
|
||||||
|
// cannot be fetched while the sidecar is restarting.
|
||||||
|
if (!port) continue;
|
||||||
|
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
for (const raw of declared.extensions) {
|
||||||
|
const ext = String(raw).toLowerCase().replace(/^\./, '');
|
||||||
|
// First claim wins. Two plugins claiming one extension is a conflict nobody has yet, and picking
|
||||||
|
// arbitrarily-but-stably beats picking differently on each request.
|
||||||
|
if (!ext || claims.has(ext)) continue;
|
||||||
|
|
||||||
|
claims.set(ext, {
|
||||||
|
plugin: state.plugin.appName,
|
||||||
|
thumbnail: declared.thumbnail ? `${base}${declared.thumbnail}` : null,
|
||||||
|
render: declared.render ? `${base}${declared.render}` : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a plugin's sidecar the way its proxy would.
|
||||||
|
*
|
||||||
|
* The email is the account the request is being made FOR, not the server — a plugin scoping anything
|
||||||
|
* per user reads this, and passing something else would be lying to it about who is asking.
|
||||||
|
*/
|
||||||
|
export async function sidecarFetch(url: string, email: string): Promise<Response> {
|
||||||
|
return fetch(url, { headers: { 'x-officer-user': email } });
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { readTtsConfig } from '@@/api/server-settings/tts';
|
|||||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||||
import { getUserSettings } from 'officerdb';
|
import { getUserSettings } from 'officerdb';
|
||||||
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
|
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
|
||||||
|
import { pluginFileTypes, sidecarFetch } from './plugin-file-types';
|
||||||
|
|
||||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -204,6 +205,12 @@ router.get('/ls', async (ctx) => {
|
|||||||
}
|
}
|
||||||
return ctx.json({ path: '/', entries: [], reset: true });
|
return ctx.json({ path: '/', entries: [], reset: true });
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
What a plugin can show that we cannot. Resolved ONCE per listing rather than per entry — it reads the
|
||||||
|
database, and a folder of two hundred files would otherwise ask two hundred times for one answer.
|
||||||
|
*/
|
||||||
|
const claimed = await pluginFileTypes();
|
||||||
|
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
names.map(async (name) => {
|
names.map(async (name) => {
|
||||||
const fullPath = resolve(absPath, name);
|
const fullPath = resolve(absPath, name);
|
||||||
@@ -228,7 +235,18 @@ router.get('/ls', async (ctx) => {
|
|||||||
|
|
||||||
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
|
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
|
||||||
*/
|
*/
|
||||||
thumb: isDirectory ? null : thumbnailKind(name),
|
/*
|
||||||
|
Core's own answer first, then a plugin's claim.
|
||||||
|
|
||||||
|
A file only says `thumb` when something can actually produce one: core for the formats sharp
|
||||||
|
and ffmpeg read, a plugin for what it declared in `fileTypes`. Without that second half, `.heic`
|
||||||
|
promised a thumbnail nothing would make and cost a 422 on every scroll — which is why core
|
||||||
|
stopped claiming it.
|
||||||
|
*/
|
||||||
|
thumb: isDirectory
|
||||||
|
? null
|
||||||
|
: (thumbnailKind(name) ??
|
||||||
|
(claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase()) ? 'image' : null)),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -375,12 +393,42 @@ router.get('/thumb', async (ctx) => {
|
|||||||
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
const absPath = resolveUserPath(rootDir, relPath);
|
const absPath = resolveUserPath(rootDir, relPath);
|
||||||
|
const size = clampSize(ctx.req.query('size'));
|
||||||
const kind = thumbnailKind(relPath);
|
const kind = thumbnailKind(relPath);
|
||||||
// 415 rather than 404: the file is there, it is simply not something a frame can be taken from. The
|
|
||||||
// client does not ask for these, so this is the answer to a stale tab rather than to normal use.
|
|
||||||
if (!kind) return ctx.text('no thumbnail for this file type', 415);
|
|
||||||
|
|
||||||
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, clampSize(ctx.req.query('size')));
|
/*
|
||||||
|
A type core cannot read, but an installed plugin can.
|
||||||
|
|
||||||
|
Forwarded to the plugin's own sidecar and cached HERE, under the same key everything else uses —
|
||||||
|
so a HEIC thumbnail is produced once and then served from disk like any other, and the plugin is
|
||||||
|
not asked again on every scroll.
|
||||||
|
|
||||||
|
`buildThumbnail` is given the fetch rather than a path: the caching, the concurrency limit and the
|
||||||
|
remembered failures are all in that one function, and a second copy of them here for this one case
|
||||||
|
is how the two would come to behave differently.
|
||||||
|
*/
|
||||||
|
if (!kind) {
|
||||||
|
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
|
||||||
|
if (claimed?.thumbnail) {
|
||||||
|
const fetched = await buildThumbnail(absPath, getUserDataDir(user.email), 'image', size, async () => {
|
||||||
|
const res = await sidecarFetch(
|
||||||
|
`${claimed.thumbnail}?path=${encodeURIComponent(absPath)}&size=${size}`,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
||||||
|
});
|
||||||
|
if (!fetched.ok) return ctx.text(fetched.reason, 422);
|
||||||
|
return new Response(Bun.file(fetched.path), {
|
||||||
|
headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, max-age=31536000, immutable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 415 rather than 404: the file is there, it is simply not something anything here can read. The
|
||||||
|
// client does not ask for these, so this answers a stale tab rather than normal use.
|
||||||
|
return ctx.text('no thumbnail for this file type', 415);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, size);
|
||||||
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
|
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
|
||||||
// the file, and logging it as a server error would bury the ones that are.
|
// the file, and logging it as a server error would bury the ones that are.
|
||||||
if (!result.ok) return ctx.text(result.reason, 422);
|
if (!result.ok) return ctx.text(result.reason, 422);
|
||||||
@@ -393,6 +441,43 @@ router.get('/thumb', async (ctx) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A displayable version of a file the browser cannot show itself.
|
||||||
|
*
|
||||||
|
* Only for types an installed plugin CLAIMS — everything a browser can already decode keeps going
|
||||||
|
* through `/raw`, which streams bytes, supports ranges and lets the browser render progressively. This
|
||||||
|
* route exists for exactly one situation: the file is an image, and no browser can display it.
|
||||||
|
*
|
||||||
|
* Not cached on disk like `/thumb`. A full-size render is large, viewed once, and keyed on a width the
|
||||||
|
* viewer chooses — caching it would fill the disk to make the second look at one photograph faster. The
|
||||||
|
* short `max-age` lets the browser hold it while somebody zooms.
|
||||||
|
*/
|
||||||
|
router.get('/render', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||||
|
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
|
||||||
|
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
|
const absPath = resolveUserPath(rootDir, relPath);
|
||||||
|
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
|
||||||
|
// 415 and not 404: the file is there. Nothing installed can show it, which is a different answer and
|
||||||
|
// the one the viewer needs in order to say so rather than spin.
|
||||||
|
if (!claimed?.render) return ctx.text('nothing installed can display this file type', 415);
|
||||||
|
|
||||||
|
const asked = Number(ctx.req.query('width'));
|
||||||
|
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 2048;
|
||||||
|
|
||||||
|
const res = await sidecarFetch(`${claimed.render}?path=${encodeURIComponent(absPath)}&width=${width}`, user.email);
|
||||||
|
if (!res.ok) return ctx.text(`${claimed.plugin} could not read this file`, 422);
|
||||||
|
|
||||||
|
return new Response(await res.arrayBuffer(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': res.headers.get('content-type') ?? 'image/webp',
|
||||||
|
'Cache-Control': 'private, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/raw', async (ctx) => {
|
router.get('/raw', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { mkdir, rm, stat } from 'node:fs/promises';
|
import { mkdir, stat } from 'node:fs/promises';
|
||||||
import { extname, join } from 'node:path';
|
import { extname, join } from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
@@ -43,7 +43,18 @@ import sharp from 'sharp';
|
|||||||
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
|
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
|
||||||
* file icons was the most visible gap this had.
|
* file icons was the most visible gap this had.
|
||||||
*/
|
*/
|
||||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg', 'heic', 'heif']);
|
/*
|
||||||
|
No `heic` or `heif`, and that is not an oversight.
|
||||||
|
|
||||||
|
sharp's bundled libheif is built without the HEVC decoder, so it parses a HEIC's header and then
|
||||||
|
fails on the pixels. Listing them here made `ls` promise a thumbnail core cannot produce: every HEIC
|
||||||
|
cost a request that 422'd before falling back to the icon, and the promise was the only reason the
|
||||||
|
request happened at all.
|
||||||
|
|
||||||
|
Decoding them needs ImageMagick and `libheif-plugin-libde265` — a system dependency the core has no
|
||||||
|
business acquiring for one format. That belongs to a plugin, and av-club already does it.
|
||||||
|
*/
|
||||||
|
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg']);
|
||||||
/*
|
/*
|
||||||
`ts` is NOT here, and that is the one entry worth explaining.
|
`ts` is NOT here, and that is the one entry worth explaining.
|
||||||
|
|
||||||
@@ -179,44 +190,6 @@ const spawnFfmpeg = async (args: string[], output: string): Promise<boolean> =>
|
|||||||
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
|
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
|
||||||
* file, and the caller already turns that into a fallback icon and a remembered failure.
|
* file, and the caller already turns that into a fallback icon and a remembered failure.
|
||||||
*/
|
*/
|
||||||
/** Extensions sharp's bundled libheif cannot decode — see `decodeViaImageMagick`. */
|
|
||||||
const NEEDS_IMAGEMAGICK = new Set(['heic', 'heif']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a HEIC into something sharp can actually read.
|
|
||||||
*
|
|
||||||
* ── Why this is necessary at all ──
|
|
||||||
*
|
|
||||||
* sharp ships its own libvips and libheif, built WITHOUT the HEVC decoder — so it parses a HEIC's
|
|
||||||
* header, reports 4032x3024 quite happily, and then fails on the pixels with "bad seek". That reads
|
|
||||||
* exactly like a corrupt file and is not one. Every photograph an iPhone takes is HEVC-coded HEIF, so
|
|
||||||
* this is not an edge case; it is most of what arrives from a phone.
|
|
||||||
*
|
|
||||||
* ImageMagick links the SYSTEM libheif, which does have the codec — provided
|
|
||||||
* `libheif-plugin-libde265` is installed. That package is the entire difference: without it,
|
|
||||||
* ImageMagick fails the same way with "Unsupported codec". Observed on two hosts running identical
|
|
||||||
* libheif 1.17.6, one working and one not.
|
|
||||||
*
|
|
||||||
* ── This is deliberately the same approach av-club already uses ──
|
|
||||||
*
|
|
||||||
* `plugins/av-club/sidecar/sharp/decodable.ts` solved this first and has been converting these exact
|
|
||||||
* files for weeks. Copied rather than shared because the platform must not import from a plugin — the
|
|
||||||
* dependency only runs the other way — and fifteen lines of spawn is a smaller cost than inverting that.
|
|
||||||
*
|
|
||||||
* PNG for the intermediate, not JPEG: it is lossless, so the single real encode is the final one and the
|
|
||||||
* thumbnail is not a copy of a copy.
|
|
||||||
*/
|
|
||||||
const decodeViaImageMagick = async (input: string, output: string): Promise<boolean> => {
|
|
||||||
const proc = Bun.spawn(['convert', `${input}[0]`, output], { stdout: 'ignore', stderr: 'ignore' });
|
|
||||||
const timer = setTimeout(() => proc.kill(), 30_000);
|
|
||||||
await proc.exited;
|
|
||||||
clearTimeout(timer);
|
|
||||||
// The file is the test, never the exit code — ImageMagick exits 0 having written nothing when the
|
|
||||||
// codec is missing, which is precisely the case this exists to handle.
|
|
||||||
const written = await stat(output).catch(() => null);
|
|
||||||
return written !== null && written.size > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
|
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
|
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
|
||||||
@@ -243,6 +216,16 @@ export async function buildThumbnail(
|
|||||||
userDataDir: string,
|
userDataDir: string,
|
||||||
kind: 'image' | 'video',
|
kind: 'image' | 'video',
|
||||||
size: number,
|
size: number,
|
||||||
|
/**
|
||||||
|
* Produce the bytes yourself instead of letting sharp or ffmpeg try.
|
||||||
|
*
|
||||||
|
* For a type core cannot read but an installed plugin can — see `plugin-file-types.ts`. It is passed
|
||||||
|
* IN rather than handled by a second function so that everything around it is shared: the cache key
|
||||||
|
* with its mtime and size, the concurrency limit, and the remembered failures. A parallel path for
|
||||||
|
* plugin thumbnails would be a second cache to keep correct and a second place to forget the
|
||||||
|
* negative caching, which is the part that stops one bad file spawning work on every scroll.
|
||||||
|
*/
|
||||||
|
produce?: () => Promise<Buffer | null>,
|
||||||
): Promise<ThumbnailResult> {
|
): Promise<ThumbnailResult> {
|
||||||
const info = await stat(absPath).catch(() => null);
|
const info = await stat(absPath).catch(() => null);
|
||||||
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' };
|
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' };
|
||||||
@@ -274,26 +257,12 @@ export async function buildThumbnail(
|
|||||||
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
|
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
|
||||||
|
|
||||||
let ok: boolean;
|
let ok: boolean;
|
||||||
if (kind === 'image') {
|
if (produce) {
|
||||||
/*
|
const bytes = await produce().catch(() => null);
|
||||||
HEIC goes through ImageMagick first. Everything else — jpeg, png, webp, avif, svg — sharp reads
|
if (bytes?.length) await Bun.write(cachePath, bytes);
|
||||||
directly, so this costs nothing for the common case.
|
ok = !!bytes?.length;
|
||||||
|
} else if (kind === 'image') {
|
||||||
The intermediate lands in the same cache directory and is removed either way: a failure here
|
ok = await resizeImage(absPath, cachePath, size);
|
||||||
must not leave a stray PNG that a later run mistakes for a finished thumbnail.
|
|
||||||
*/
|
|
||||||
const ext = extname(absPath).slice(1).toLowerCase();
|
|
||||||
if (NEEDS_IMAGEMAGICK.has(ext)) {
|
|
||||||
const intermediate = `${cachePath}.decoded.png`;
|
|
||||||
try {
|
|
||||||
ok =
|
|
||||||
(await decodeViaImageMagick(absPath, intermediate)) && (await resizeImage(intermediate, cachePath, size));
|
|
||||||
} finally {
|
|
||||||
await rm(intermediate, { force: true });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ok = await resizeImage(absPath, cachePath, size);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
|
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
|
||||||
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
||||||
@@ -303,7 +272,19 @@ export async function buildThumbnail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
rememberFailure(key);
|
/*
|
||||||
|
A PRODUCER'S FAILURE IS NOT REMEMBERED, and that distinction is the whole point of this branch.
|
||||||
|
|
||||||
|
When sharp or ffmpeg fails, the file is the problem — it will fail identically forever, and
|
||||||
|
remembering that is what stops one bad video spawning work on every scroll.
|
||||||
|
|
||||||
|
A producer is a PLUGIN, in another process, reachable over a socket. It fails while it restarts,
|
||||||
|
while it is busy behind its own concurrency limit, and for the few seconds after an update. Those
|
||||||
|
all pass. Caching them meant a folder of HEIC came back as file icons and stayed that way until
|
||||||
|
the SERVER was restarted — no client retry could ever get past it, because the answer was being
|
||||||
|
served from memory without the plugin being asked again.
|
||||||
|
*/
|
||||||
|
if (!produce) rememberFailure(key);
|
||||||
return { ok: false, reason: 'could not read an image from this file' };
|
return { ok: false, reason: 'could not read an image from this file' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -477,6 +477,38 @@ export type PluginManifest = {
|
|||||||
settings?: PluginSetting[];
|
settings?: PluginSetting[];
|
||||||
|
|
||||||
websocket?: { sidecar: string };
|
websocket?: { sidecar: string };
|
||||||
|
/**
|
||||||
|
* File types this plugin can show that the platform cannot.
|
||||||
|
*
|
||||||
|
* ── Why this exists ──
|
||||||
|
*
|
||||||
|
* The file browser's thumbnailer reads what sharp and ffmpeg read, and that list is a constant in
|
||||||
|
* core. HEIC is the case that forced the question: sharp's bundled libheif has no HEVC decoder, and
|
||||||
|
* teaching core to shell out to ImageMagick would mean the platform acquiring a system dependency for
|
||||||
|
* one format — which is a plugin's job, not core's.
|
||||||
|
*
|
||||||
|
* So a plugin may CLAIM extensions. `ls` unions these with core's own, so a file only promises a
|
||||||
|
* thumbnail when something can actually produce one; `/thumb` forwards a claimed type to the plugin
|
||||||
|
* that claimed it; and the viewer asks for `render` when opening one full size.
|
||||||
|
*
|
||||||
|
* ── The dependency still points the right way ──
|
||||||
|
*
|
||||||
|
* Core does not import plugin code and does not know av-club exists. It reads a claim at runtime from
|
||||||
|
* whatever is installed and enabled, calls a route by name, and falls back to a file icon when nothing
|
||||||
|
* claims the type. Uninstall the plugin and the file browser is exactly what it was.
|
||||||
|
*
|
||||||
|
* `thumbnail` and `render` are paths on the plugin's own sidecar, called with `?path=<absolute>` —
|
||||||
|
* plus `&size=` for a thumbnail and `&width=` for a render. Both must answer image bytes.
|
||||||
|
*/
|
||||||
|
fileTypes?: {
|
||||||
|
/** Lowercase, no leading dot. `['heic', 'heif']`. */
|
||||||
|
extensions: string[];
|
||||||
|
/** Sidecar path returning a small image. Omit and the platform will not offer thumbnails for these. */
|
||||||
|
thumbnail?: string;
|
||||||
|
/** Sidecar path returning a full-size displayable image. Omit and the viewer will not offer them. */
|
||||||
|
render?: string;
|
||||||
|
};
|
||||||
|
|
||||||
osDependencies?: OsDependency[];
|
osDependencies?: OsDependency[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+12
-1
@@ -629,7 +629,18 @@ export const FileItem = ({
|
|||||||
</Thumbnail>
|
</Thumbnail>
|
||||||
);
|
);
|
||||||
|
|
||||||
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 128) : icon;
|
/*
|
||||||
|
ONE SIZE FOR BOTH VIEWS, and it is 256 rather than the 128 the list used to ask for.
|
||||||
|
|
||||||
|
The size is part of the cache key, so two sizes meant two entries — and switching from grid to list
|
||||||
|
re-rendered every picture from scratch. For a jpeg that is a few milliseconds nobody notices. For a
|
||||||
|
HEIC, which a plugin decodes, it is four seconds per file: the list showed icons where the grid had
|
||||||
|
just shown photographs, and the two views appeared to disagree about the same file.
|
||||||
|
|
||||||
|
The list draws it at 28px and the grid at about 80, so both are downscaling either way and 256 is
|
||||||
|
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
|
||||||
|
*/
|
||||||
|
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
|
||||||
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
|
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+32
-2
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Play } from 'lucide-react';
|
import { Play } from 'lucide-react';
|
||||||
import { getHeaders } from 'hooks/useClient';
|
import { getHeaders } from 'hooks/useClient';
|
||||||
|
|
||||||
@@ -33,6 +33,21 @@ const API_URL = '/api';
|
|||||||
* every one of them would spawn an ffmpeg for something nobody has looked at.
|
* every one of them would spawn an ffmpeg for something nobody has looked at.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
How many times to try before settling for the icon, and how long to wait between.
|
||||||
|
|
||||||
|
A thumbnail can legitimately fail for a REASON THAT PASSES: the plugin that renders it is restarting,
|
||||||
|
the server is busy behind its concurrency cap, or — for a format that needs decoding — the first
|
||||||
|
request is simply slow enough that something upstream gave up. Treating the first failure as final
|
||||||
|
meant a folder of HEIC came back as file icons and stayed that way until somebody reloaded the page,
|
||||||
|
which is what made it look like a race rather than a retry that never happened.
|
||||||
|
|
||||||
|
Backing off rather than hammering: a genuinely undecodable file must not become a request loop, and
|
||||||
|
three attempts over about seven seconds is enough to outlast a sidecar restart without becoming one.
|
||||||
|
*/
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
const RETRY_DELAYS_MS = [1200, 3000, 6000];
|
||||||
|
|
||||||
export const Thumbnail = ({
|
export const Thumbnail = ({
|
||||||
path,
|
path,
|
||||||
root,
|
root,
|
||||||
@@ -53,6 +68,12 @@ export const Thumbnail = ({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
|
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// A pending retry must not fire into an unmounted row — the grid is virtualised, so scrolling past a
|
||||||
|
// slow thumbnail unmounts it mid-wait.
|
||||||
|
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
|
||||||
|
|
||||||
if (state === 'failed') return <>{children}</>;
|
if (state === 'failed') return <>{children}</>;
|
||||||
|
|
||||||
@@ -72,8 +93,17 @@ export const Thumbnail = ({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
// `attempt` is in the key, so a retry REMOUNTS the element. Changing only the src would leave
|
||||||
|
// the browser's own failed-request cache in play and the second attempt would not be one.
|
||||||
|
key={attempt}
|
||||||
onLoad={() => setState('ready')}
|
onLoad={() => setState('ready')}
|
||||||
onError={() => setState('failed')}
|
onError={() => {
|
||||||
|
if (attempt + 1 >= MAX_ATTEMPTS) {
|
||||||
|
setState('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer.current = setTimeout(() => setAttempt((n) => n + 1), RETRY_DELAYS_MS[attempt] ?? 6000);
|
||||||
|
}}
|
||||||
className={`size-full object-cover transition-opacity duration-150 ${
|
className={`size-full object-cover transition-opacity duration-150 ${
|
||||||
state === 'ready' ? 'opacity-100' : 'opacity-0'
|
state === 'ready' ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { Loader2, FolderArchive, FileWarning, Download } from 'lucide-react';
|
import { Loader2, FolderArchive, FileWarning, Download } from 'lucide-react';
|
||||||
import { getLang, getRawUrl, getArchiveBaseName } from './file-types';
|
import { getLang, getRawUrl, getRenderUrl, needsServerRender, getArchiveBaseName } from './file-types';
|
||||||
import { useFileViewer } from './FileViewerContext';
|
import { useFileViewer } from './FileViewerContext';
|
||||||
import { PdfRenderer } from './renderers/PdfRenderer';
|
import { PdfRenderer } from './renderers/PdfRenderer';
|
||||||
import { ImageRenderer } from './renderers/ImageRenderer';
|
import { ImageRenderer } from './renderers/ImageRenderer';
|
||||||
@@ -63,7 +63,15 @@ export const FileViewerBody = () => {
|
|||||||
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'image' ? (
|
) : fileType === 'image' ? (
|
||||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
<ImageRenderer
|
||||||
|
/*
|
||||||
|
HEIC and friends go through the server, because no browser decodes them. Everything else
|
||||||
|
keeps using `/raw`: it streams, supports ranges, and lets the browser paint progressively —
|
||||||
|
all of which a rendered copy gives up.
|
||||||
|
*/
|
||||||
|
src={needsServerRender(fileName) ? getRenderUrl(filePath, root) : getRawUrl(filePath, root)}
|
||||||
|
fileName={fileName}
|
||||||
|
/>
|
||||||
) : fileType === 'video' ? (
|
) : fileType === 'video' ? (
|
||||||
<VideoRenderer src={videoSrc} fileName={fileName} filePath={filePath} root={root} />
|
<VideoRenderer src={videoSrc} fileName={fileName} filePath={filePath} root={root} />
|
||||||
) : fileType === 'audio' ? (
|
) : fileType === 'audio' ? (
|
||||||
|
|||||||
@@ -124,6 +124,34 @@ export function formatTime(s: number): string {
|
|||||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image formats NO browser can display, whatever the server does.
|
||||||
|
*
|
||||||
|
* This is a fact about browsers, not about plugins — which is why it is a constant here rather than
|
||||||
|
* something fetched. Chrome and Firefox have never decoded HEIC and Safari only does on Apple's own
|
||||||
|
* platforms, so a `<img src>` pointed at one shows a broken image everywhere it matters.
|
||||||
|
*
|
||||||
|
* The server may or may not be able to render one into something showable; that depends on whether a
|
||||||
|
* plugin claims the type, and `/file-browser/render` answers 415 when nothing does.
|
||||||
|
*/
|
||||||
|
export const BROWSER_CANNOT_DISPLAY = ['heic', 'heif'];
|
||||||
|
|
||||||
|
export const needsServerRender = (name: string): boolean =>
|
||||||
|
BROWSER_CANNOT_DISPLAY.includes(name.split('.').pop()?.toLowerCase() ?? '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A displayable version of a file the browser cannot decode. 415 when nothing installed can make one.
|
||||||
|
*
|
||||||
|
* `width` is what the viewer will draw at, doubled for retina and clamped server-side — asking for the
|
||||||
|
* full 4032px of a phone photograph to show it in a panel is megabytes nobody looks at.
|
||||||
|
*/
|
||||||
|
export function getRenderUrl(filePath: string, root?: string, width = 2048): string {
|
||||||
|
const headers = getHeaders();
|
||||||
|
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
|
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||||
|
return `${API_URL}/file-browser/render?path=${encodeURIComponent(filePath)}&width=${width}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRawUrl(filePath: string, root?: string, audioTrack?: number): string {
|
export function getRawUrl(filePath: string, root?: string, audioTrack?: number): string {
|
||||||
const headers = getHeaders();
|
const headers = getHeaders();
|
||||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
|
|||||||
Reference in New Issue
Block a user