diff --git a/README.md b/README.md index d91f407..d0258e7 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ Bedienung. **Ausliefern** — mit ETag, `304`, Bereichsanfragen und `nosniff`. Vorschaubilder werden beim Upload erzeugt (320 px WebP): aus 7,5 MB je Galerieseite werden -766 KB. +766 KB. Bei **Video** ist es ein Standbild aus dem Clip (ein Zehntel hinein, weil +viele mit Schwarz beginnen), bei **Ton** das eingebettete Titelbild; die Kachel +trägt zusätzlich die Laufzeit. **Sparen, ohne Adressen zu ändern** — neben jedem PNG und JPEG liegt eine WebP-Fassung, ausgeliefert unter *derselben* Adresse je nach `Accept` des diff --git a/ROADMAP.md b/ROADMAP.md index e0ff705..2644956 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -601,6 +601,74 @@ gemeldeten Art. Grund: `items/readme.md` lag schon da und trug wurde. Wer nur auf die Art schaut, zeigt die alte README nicht an und eine neue schon — und sucht den Unterschied an der falschen Stelle. +### ✅ Fertig — Vorschau für Video und Ton, und zwei Ordner-Lücken + +Mit den ersten echten Clips fiel auf, was bei 3600 Bildern nie auffiel: alles, +was kein Bild ist, bekam ein **Fragezeichen** auf die Kachel — also ausgerechnet +das Zeichen für „unbekannt" auf einer völlig gesunden MP4. + +**Jetzt:** Standbild aus dem Clip (ein Zehntel hinein, weil viele mit Schwarz +beginnen), bei Ton das eingebettete Titelbild, dazu ein Abspielzeichen und die +**Laufzeit** in der Ecke. Fehlt eine Vorschau, kommt ein Zeichen, das die *Art* +benennt, statt eines Fragezeichens. + +**Der Preis: ffmpeg im Abbild** — und der wurde zweimal gemessen und zweimal +gedrückt: + +| Weg | Abbild | +|---|---| +| vorher | 460 MB | +| `apt-get install ffmpeg` | **1,11 GB** | +| statisches ffmpeg **+** ffprobe | 835 MB | +| statisches ffmpeg **allein** | **648 MB** | + +Debians Paket schaltet jeden Filter frei und zieht die Abhängigkeiten mit: +`libllvm15` (114 MB), Mesa samt `libgl1-mesa-dri`, `libz3`, und `libflite1` — +ein **Sprachsynthesizer**. Alles für Filter, die ein Dienst ohne Bildschirm nie +anfasst; `--no-install-recommends` half nicht, es sind harte Abhängigkeiten. + +`ffprobe` flog danach auch noch raus: 135 MB für eine einzige Aufgabe, die +Länge zu nennen — die steht auch im Kopf, den `ffmpeg` beim Öffnen druckt. +Gegengeprüft an beiden Dateien, auf zwei Nachkommastellen identisch: + +``` +ffprobe 29.767967 | ffmpeg Duration: 00:00:29.77 +ffprobe 645.642449 | ffmpeg Duration: 00:10:45.64 +``` + +**Ein Fehler, der zweimal derselbe war.** „Keine Bildspur" ist bei Ton der +Normalfall — die meisten MP3 tragen kein Titelbild. Der erste Anlauf erkannte +das am *Wortlaut* der Fehlermeldung, und der ist zwischen den Fassungen anders: + +``` +ffmpeg 5.1 "Stream map '0:v:0' matches no streams." +ffmpeg 7.1 "Failed to set value '0:v:0' for option 'map'" +``` + +Ergebnis: eine gesunde MP3 wurde als **„1 fehlgeschlagen"** gezählt. Gelesen +wird jetzt die *Spurenliste* (`Stream #0:1: Video:`), die seit jeher so +dasteht — und die kommt aus demselben Aufruf wie die Länge, also ein Blick in +die Datei statt drei. Gemessen: `fehler: 0, ohneBild: 1`. + +Beide Wege durchgeprüft — Video: 320×180, Durchschnittsfarbe RGB(97,140,155), +also echtes Bild und kein schwarzer Vorspann. Ton mit angehängtem Titelbild: +Vorschau erzeugt, `dauer: 8.05` → Kachel zeigt `0:08`. + +**Und zwei Ordner-Lücken, beide vom Benutzer gefunden:** + +1. **„Wie lege ich neue Ordner an?"** — Gar nicht, außer man wusste es: der + Knopf saß **nur im Verschieben-Dialog**. Wer einen leeren Ordner wollte, + musste also erst Dateien auswählen und „Verschieben" drücken. Eine Funktion, + die man nur über eine andere Funktion erreicht, ist für den, der sie sucht, + nicht vorhanden. Jetzt steht sie unter dem Ordnerbaum — mit der Angabe, *in* + welchem Ordner sie entsteht. +2. **Zielordner beim Hochladen.** Vorher galt stillschweigend der in der + Galerie angeklickte Ordner. Richtig, solange man es weiß — und ein + Fallstrick, sobald man es nicht weiß: wer nach einer Suche hochlädt, steht + in „Alle" und legt an der Wurzel ab. Der angeklickte Ordner bleibt die + Voreinstellung, ist jetzt aber sichtbar und änderbar, samt „neuer Ordner" + direkt daneben. + ### ⬜ Als Nächstes **Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container diff --git a/server/Dockerfile b/server/Dockerfile index f6a0bb9..20aeff8 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -55,6 +55,36 @@ FROM node:22-bookworm-slim AS runtime ENV NODE_ENV=production WORKDIR /app +# ffmpeg -- fuer EIN EINZIGES Bild je Video, und trotzdem die groesste einzelne +# Zutat dieses Abbilds. +# +# DIE ABWAEGUNG, ausgesprochen: eine Kachel ohne Vorschau ist ein Fragezeichen. +# Bei 3600 Bildern faellt das nicht auf, bei einer Handvoll Clips ist die +# Galerie dann eine Liste von Dateinamen -- und dafuer braucht es keine +# Galerie. Ein Standbild kann sharp nicht liefern: libvips dekodiert kein +# Video, das ist kein Einstellungsproblem. +# +# Gerechnet wird NUR beim Hochladen und beim Nachziehen, nie beim Ausliefern. +# ffmpeg laeuft also nicht im Anfrageweg. +# +# WARUM NICHT `apt-get install ffmpeg` -- nachgemessen und wieder verworfen: +# Debians Paket schaltet jeden Filter frei und zieht die Abhaengigkeiten dafuer +# mit. Das Abbild wuchs von 460 MB auf 1,11 GB, und die groessten Brocken +# waren libllvm15 (114 MB), Mesa samt libgl1-mesa-dri (25 MB), libz3 (22 MB) +# und libflite1 (27 MB, ein SPRACHSYNTHESIZER). Alles fuer Filter, die ein +# Dienst ohne Bildschirm nie anfasst; --no-install-recommends half nicht, denn +# es sind harte Abhaengigkeiten. +# +# Also die statischen Binaerdateien, auf eine Fassung FESTGENAGELT. Der Preis +# dafuer ist ehrlich zu benennen: Aktualisierungen kommen nicht mehr ueber apt, +# sondern nur, wenn jemand diese Zeile anfasst. Vertretbar, weil ffmpeg hier +# nur eigene, bereits abgelegte Dateien anfasst und nicht am Netz haengt. +# NUR ffmpeg, NICHT ffprobe: das zweite Werkzeug waegt noch einmal 135 MB und +# haette hier eine einzige Aufgabe gehabt -- die Laenge zu nennen. Die steht +# auch im Kopf, den ffmpeg beim Oeffnen druckt; nachgemessen an beiden Dateien, +# auf zwei Nachkommastellen identisch (siehe medienDauer in storage.ts). +COPY --from=mwader/static-ffmpeg:7.1 /ffmpeg /usr/local/bin/ + COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ diff --git a/server/src/db.ts b/server/src/db.ts index 364c5e3..f5bea46 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -167,6 +167,14 @@ CREATE TABLE IF NOT EXISTS settings ( // Abrufe. Die Frage "was braucht eigentlich noch jemand" laesst sich von // innen nicht beantworten -- von aussen schon: wer wird geholt, wie oft, // wann zuletzt. Das ist die Zahl, die dem Aufbewahrungsbericht fehlt. + // Die Laenge in Sekunden -- nur bei Video und Ton gefuellt. Beantwortet + // auf der Kachel die Frage, die ein Standbild nicht beantwortet: WIE LANG. + const dSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[] + if (!dSpalten.some((s) => s.name === 'dauer')) { + db.exec('ALTER TABLE media ADD COLUMN dauer REAL') + console.log('[db] Spalte media.dauer angelegt') + } + const mSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[] if (!mSpalten.some((s) => s.name === 'abrufe')) { db.exec('ALTER TABLE media ADD COLUMN abrufe INTEGER NOT NULL DEFAULT 0') @@ -272,6 +280,8 @@ export type Media = { /** Wie oft die Datei oeffentlich geholt wurde. */ abrufe: number zuletzt_abgerufen: number | null + /** Laenge in Sekunden — nur bei Video und Ton, sonst null. */ + dauer: number | null } export type PapierkorbEintrag = { diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index 309b6d4..9620031 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -55,6 +55,7 @@ import { ausDemPapierkorb, deleteFile, inDenPapierkorb, + kannBewegtVorschau, kannVorschau, lohntWebp, mimeFor, @@ -65,6 +66,7 @@ import { papierkorbPfad, thumbPath, webpPfad, + writeBewegtThumb, writeFileAtomic, writeThumb, writeWebp, @@ -828,17 +830,25 @@ dashRoutes.post('/upload', async (c) => { if (kannVorschau(mime)) await writeThumb(path, data) if (lohntWebp(mime)) await writeWebp(path, data) + // Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und + // deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge + // faellt beim selben Blick in die Datei mit ab. + let dauer: number | null = null + if (kannBewegtVorschau(mime)) { + dauer = (await writeBewegtThumb(path, mime)).dauer + } + const zeit = now() if (vorhanden) { db.prepare( - `UPDATE media SET size = ?, sha256 = ?, mime = ?, updated_at = ? + `UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, updated_at = ? WHERE id = ?`, - ).run(data.length, digest, mime, zeit, vorhanden.id) + ).run(data.length, digest, mime, dauer, zeit, vorhanden.id) } else { db.prepare( - `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) - VALUES (?, ?, ?, ?, NULL, ?, ?)`, - ).run(path, data.length, digest, mime, zeit, zeit) + `INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, + ).run(path, data.length, digest, mime, dauer, zeit, zeit) } logEvent( @@ -2082,9 +2092,9 @@ dashRoutes.post('/maintenance/orphans', async (c) => { const THUMB_STAPEL = 300 dashRoutes.post('/maintenance/thumbs', async (c) => { - const rows = db.prepare('SELECT path, mime FROM media').all() as Pick< + const rows = db.prepare('SELECT id, path, mime FROM media').all() as Pick< Media, - 'path' | 'mime' + 'id' | 'path' | 'mime' >[] let erzeugt = 0 @@ -2092,9 +2102,12 @@ dashRoutes.post('/maintenance/thumbs', async (c) => { let uebersprungen = 0 let fehler = 0 let offen = 0 + /** Ton ohne eingebettetes Titelbild. Kein Fehler — der Normalfall. */ + let ohneBild = 0 for (const row of rows) { - if (!kannVorschau(row.mime)) { + const bewegt = kannBewegtVorschau(row.mime) + if (!kannVorschau(row.mime) && !bewegt) { uebersprungen++ continue } @@ -2109,15 +2122,30 @@ dashRoutes.post('/maintenance/thumbs', async (c) => { // Stapelweise, aus demselben Grund wie unten bei den sparsamen // Fassungen: nach 90 Sekunden gibt der Proxy auf, und ein 504 auf einen // Knopf, dessen Arbeit weiterlaeuft, ist eine Luege. - if (erzeugt + fehler >= THUMB_STAPEL) { + // + // Ein Standbild aus einem Video kostet DEUTLICH mehr als eines aus + // einem PNG -- ffmpeg muss dafuer einen Container oeffnen und + // hineinspringen. Es zaehlt hier trotzdem als eins; der Stapel ist mit + // 300 klein genug, dass auch 300 Videos in die neunzig Sekunden passen. + if (erzeugt + fehler + ohneBild >= THUMB_STAPEL) { offen++ continue } try { - const data = await readFile(absolutePath(row.path)) - if (await writeThumb(row.path, data)) erzeugt++ - else fehler++ + if (bewegt) { + const { ergebnis, dauer } = await writeBewegtThumb(row.path, row.mime) + if (ergebnis === 'ok') erzeugt++ + else if (ergebnis === 'keinBild') ohneBild++ + else fehler++ + // Die Laenge faellt beim selben Blick in die Datei mit ab -- + // auch dann, wenn es kein Titelbild gab. + db.prepare('UPDATE media SET dauer = ? WHERE id = ?').run(dauer, row.id) + } else { + const data = await readFile(absolutePath(row.path)) + if (await writeThumb(row.path, data)) erzeugt++ + else fehler++ + } } catch { // Ein Datensatz ohne Datei. Kommt vor, wenn jemand im Volume // aufgeraeumt hat; hier ist es ein Zaehler und kein Absturz. @@ -2130,6 +2158,7 @@ dashRoutes.post('/maintenance/thumbs', async (c) => { vorhanden, uebersprungen, fehler, + ohneBild, gesamt: rows.length, offen, fertig: offen === 0, diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 066b8c8..9c1c3e1 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -30,12 +30,14 @@ import { deleteFile, extensionFor, fileExists, + kannBewegtVorschau, kannVorschau, lohntWebp, mimeFor, publicUrlFor, sha256, writeFileAtomic, + writeBewegtThumb, writeThumb, writeWebp, } from '../storage.js' @@ -210,17 +212,25 @@ uploadRoutes.post('/upload', async (c) => { if (kannVorschau(mime)) await writeThumb(path, data) if (lohntWebp(mime)) await writeWebp(path, data) + // Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und + // deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge faellt + // beim selben Blick in die Datei mit ab. + let dauer: number | null = null + if (kannBewegtVorschau(mime)) { + dauer = (await writeBewegtThumb(path, mime)).dauer + } + const timestamp = now() if (existing) { db.prepare( - `UPDATE media SET size = ?, sha256 = ?, mime = ?, token_id = ?, updated_at = ? + `UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ? WHERE id = ?`, - ).run(data.length, digest, mime, token.id, timestamp, existing.id) + ).run(data.length, digest, mime, dauer, token.id, timestamp, existing.id) } else { db.prepare( - `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run(path, data.length, digest, mime, token.id, timestamp, timestamp) + `INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(path, data.length, digest, mime, dauer, token.id, timestamp, timestamp) } logEvent(existing ? 'replace' : 'upload', path, data.length, 'token', token.name) diff --git a/server/src/storage.ts b/server/src/storage.ts index 96e9054..f90cd40 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -10,6 +10,7 @@ // unter einem anderen Namen als dem, den der Aufrufer erwartet, und niemand // merkt es, bis das Bild fehlt. +import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join, resolve, sep } from 'node:path' @@ -243,6 +244,174 @@ export async function writeThumb(path: string, data: Buffer): Promise { } } +// ------------------------------------------- Vorschau fuer Video und Ton +// +// Bis hierhin bekam alles, was kein Bild ist, ein Fragezeichen auf die Kachel. +// Bei 3600 Bildern faellt das nicht auf; bei einer Handvoll Clips ist die +// Galerie dann eine Liste von Dateinamen. +// +// VIDEO: ein Standbild. sharp kann das nicht -- libvips dekodiert kein Video. +// Also ffmpeg, und zwar auf die Datei AUF DER PLATTE und nicht auf den Puffer: +// ein Container wie MP4 hat seine Inhaltsangabe je nach Kodierer am Ende, und +// ohne Springen findet ffmpeg sie nicht. +// +// TON: das eingebettete Titelbild, wenn eines da ist. Fuer ffmpeg ist das +// schlicht die Videospur der Datei -- derselbe Aufruf, andere Zeitmarke. +// Ist keines da, entsteht keine Vorschau, und die Kachel zeigt ein Notenzeichen +// statt eines leeren Rahmens. + +export const kannBewegtVorschau = (mime: string) => + /^(video|audio)\//.test(mime.split(';')[0]?.trim().toLowerCase() ?? '') + +/** Wie lange wir ffmpeg zugestehen. + * + * Ein beschaedigter Container kann einen Dekodierer beliebig lange + * beschaeftigen. 20 Sekunden reichen fuer ein Standbild aus jeder gesunden + * Datei -- was laenger braucht, bekommt eben keine Vorschau. */ +const FFMPEG_MS = 20_000 + +/** Was in der Datei steckt: wie lang, und ob es ueberhaupt ein Bild gibt. + * + * BEIDES AUS EINEM AUFRUF. `ffmpeg -i datei` ohne Ausgabeziel bricht mit einem + * Fehler ab ("At least one output file must be specified") -- seinen Kopf hat + * es da aber laengst gedruckt, und darin steht alles, was wir brauchen. Der + * Rueckgabewert wird deshalb bewusst ignoriert und nur stderr gelesen. Kein + * `-f null -`: das dekodierte die ganze Datei, um am Ende dasselbe zu wissen. + * + * OHNE ffprobe, und das ist eine gemessene Entscheidung: das Werkzeug haette + * als statische Binaerdatei 135 MB ins Abbild gebracht. Gegengeprueft an + * beiden Dateien, die hier liegen, auf zwei Nachkommastellen identisch: + * + * ffprobe 29.767967 | ffmpeg Duration: 00:00:29.77 + * ffprobe 645.642449 | ffmpeg Duration: 00:10:45.64 + * + * Fuer eine Beschriftung wie "0:29" ist das genauer als noetig. + * + * `hatBild` WIRD AUS DER SPURENLISTE GELESEN und nicht aus einer + * Fehlermeldung. Genau daran ist der erste Anlauf gescheitert: er erkannte + * "keine Bildspur" am Wortlaut, und der ist zwischen den Fassungen anders -- + * + * ffmpeg 5.1 "Stream map '0:v:0' matches no streams." + * ffmpeg 7.1 "Failed to set value '0:v:0' for option 'map'" + * + * -- worauf eine voellig gesunde MP3 als "1 fehlgeschlagen" gezaehlt wurde. + * Die Zeile "Stream #0:1: Video:" dagegen steht seit jeher so da. */ +export type MedienInfo = { dauer: number | null; hatBild: boolean } + +export async function medienInfo(path: string): Promise { + const { stderr } = await ffmpegRoh(['-i', absolutePath(path)]) + + // "Duration: 00:10:45.64," -- bei Stroemen ohne Laengenangabe "Duration: N/A". + const treffer = /Duration:\s*(\d+):(\d\d):(\d\d(?:\.\d+)?)/.exec(stderr) + const sekunden = treffer + ? Number(treffer[1]) * 3600 + Number(treffer[2]) * 60 + Number(treffer[3]) + : NaN + + return { + dauer: Number.isFinite(sekunden) && sekunden > 0 ? sekunden : null, + // Ein eingebettetes Titelbild ist fuer ffmpeg eine Bildspur: + // "Stream #0:1: Video: mjpeg ... (attached pic)". + hatBild: /Stream #\d+:\d+[^\n]*:\s*Video:/.test(stderr), + } +} + +/** Was bei einem Versuch herauskam. + * + * DREI Zustaende und nicht zwei, und das ist nachgemessen: eine MP3 OHNE + * eingebettetes Titelbild ist voellig in Ordnung -- sie hat schlicht keine + * Bildspur. Mit einem blossen true/false zaehlte der Nachzieh-Knopf sie als + * "1 fehlgeschlagen", und dann sucht jemand einen Fehler, den es nicht gibt. + * Genau so ist es beim ersten Lauf passiert. */ +export type BewegtErgebnis = 'ok' | 'keinBild' | 'fehler' + +/** Ein Standbild bzw. Titelbild als Vorschau, samt Laenge. + * + * Gibt BEIDES zurueck, weil beides aus demselben Blick in die Datei kommt: ein + * zweiter Aufruf nur fuer die Sekundenzahl waere die doppelte Arbeit. + * + * Wie writeThumb: ein Fehlschlag ist KEIN Grund, den Upload scheitern zu + * lassen. */ +export async function writeBewegtThumb( + path: string, + mime: string, +): Promise<{ ergebnis: BewegtErgebnis; dauer: number | null }> { + const ziel = thumbPath(path) + const quelle = absolutePath(path) + const istVideo = mime.startsWith('video') + + const info = await medienInfo(path) + + // Keine Bildspur: bei Ton der Normalfall (die meisten MP3 tragen kein + // Titelbild). Hier wird ffmpeg gar nicht erst ein zweites Mal bemueht. + if (!info.hatBild) return { ergebnis: 'keinBild', dauer: info.dauer } + + try { + await mkdir(dirname(ziel), { recursive: true }) + + // Bei Video NICHT den Anfang nehmen, wenn sich das vermeiden laesst: + // viele Clips beginnen mit Schwarz oder einem Einblender, und eine + // schwarze Kachel ist so aussagekraeftig wie gar keine. Ein Zehntel + // hinein ist fast immer Bild. + // + // Bei Ton NICHT springen: das eingebettete Titelbild ist ein einzelnes + // Standbild, und ein -ss darauf findet nichts mehr. + const start = istVideo && info.dauer ? Math.min(info.dauer * 0.1, 10) : 0 + + const temp = `${ziel}.${process.pid}.tmp.webp` + await ffmpeg([ + // -ss VOR -i: dann springt ffmpeg, statt bis dorthin zu dekodieren. + ...(start > 0 ? ['-ss', start.toFixed(2)] : []), + '-i', quelle, + // Nur die Bildspur, genau ein Bild. Bei einer MP3 ist das + // eingebettete Titelbild genau diese Spur. + '-map', '0:v:0', + '-frames:v', '1', + // Nicht aufblasen: ein 64 Pixel breites Titelbild bleibt klein. + '-vf', `scale='min(${THUMB_BREITE},iw)':-2`, + '-f', 'webp', + '-y', temp, + ]) + + await rename(temp, ziel) + return { ergebnis: 'ok', dauer: info.dauer } + } catch (err) { + console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`) + return { ergebnis: 'fehler', dauer: info.dauer } + } +} + +/** ffmpeg aufrufen, OHNE zu urteilen: gibt zurueck, was herauskam, samt + * Rueckgabewert. Fuer den Fall, dass ein Fehlschlag die Antwort IST -- siehe + * medienDauer. + * + * execFile und NICHT exec: ohne Shell dazwischen gibt es keine Zeichenkette, + * in der ein Dateiname etwas anderes bedeuten koennte. Der Pfad ist zwar durch + * checkPath gegangen, aber diese Abwehr kostet nichts. */ +function ffmpegRoh(args: string[]): Promise<{ code: number | null; stderr: string }> { + return new Promise((erfuellen) => { + execFile( + 'ffmpeg', + args, + { timeout: FFMPEG_MS, maxBuffer: 4 * 1024 * 1024 }, + (fehler, _stdout, stderr) => { + erfuellen({ + code: fehler ? ((fehler as { code?: number }).code ?? 1) : 0, + stderr, + }) + }, + ) + }) +} + +/** Dasselbe, aber ein Fehlschlag ist ein Fehlschlag. */ +async function ffmpeg(args: string[]): Promise { + const { code, stderr } = await ffmpegRoh(args) + if (code !== 0) { + // Die letzte Zeile von ffmpeg ist die, die den Grund nennt. + throw new Error(stderr.trim().split('\n').pop() ?? `ffmpeg endete mit ${code}`) + } +} + /** Eine Datei samt Vorschau an einen anderen Pfad legen. * * DER PFAD IST DER SCHLUESSEL — deshalb ist Verschieben hier kein Kopieren diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index ff903df..510aa00 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -113,6 +113,8 @@ export type MediaItem = { /** Wie oft die Datei oeffentlich geholt wurde. */ abrufe: number zuletzt_abgerufen: number | null + /** Laenge in Sekunden — nur bei Video und Ton, sonst null. */ + dauer: number | null } export type MediaPage = { @@ -343,6 +345,8 @@ export type ThumbLauf = { /** Wieviele noch fehlen. Siehe WebpLauf — derselbe Grund. */ offen: number fertig: boolean + /** Ton ohne eingebettetes Titelbild. KEIN Fehler — der Normalfall. */ + ohneBild: number } /** Die Adresse der Vorschau zu einem Pfad. diff --git a/server/ui/src/components/Hochladen.tsx b/server/ui/src/components/Hochladen.tsx index a0ed11f..4aacedb 100644 --- a/server/ui/src/components/Hochladen.tsx +++ b/server/ui/src/components/Hochladen.tsx @@ -1,7 +1,7 @@ -import { useRef, useState } from 'react' -import { CheckCircle2, Upload, X, XCircle } from 'lucide-react' -import type { UploadErgebnis } from '../api' -import { useStore } from '../store' +import { useEffect, useRef, useState } from 'react' +import { CheckCircle2, FolderPlus, Upload, X, XCircle } from 'lucide-react' +import { api, type Folders, type UploadErgebnis } from '../api' +import { run, useStore } from '../store' import { bytes } from '../format' import { Knopf } from './Bausteine' @@ -14,15 +14,50 @@ import { Knopf } from './Bausteine' * * JEDE Datei bekommt ihre eigene Zeile mit Ergebnis. "3 von 5 hochgeladen" * ohne die Namen der zwei anderen ist genau die Sorte Rückmeldung, wegen der - * wir hier neu bauen. */ -export function Hochladen({ ordner, fertig }: { ordner: string; fertig: () => void }) { + * wir hier neu bauen. + * + * DER ZIELORDNER WIRD HIER GEWÄHLT und nicht nur nebenan angezeigt. Vorher + * galt stillschweigend der in der Galerie angeklickte Ordner — das ist + * richtig, solange man es weiß, und ein Fallstrick, sobald man es nicht weiß: + * wer nach einer Suche hochlädt, steht in „Alle" und legt an der Wurzel ab. + * Der angeklickte Ordner ist deshalb weiterhin die VOREINSTELLUNG, aber eben + * eine sichtbare und änderbare. */ +export function Hochladen({ + ordner, + ordnerDaten, + fertig, +}: { + ordner: string + ordnerDaten: Folders | null + fertig: () => void +}) { const note = useStore((s) => s.note) + const [ziel, setZiel] = useState(ordner) + const [neu, setNeu] = useState(null) const [ueber, setUeber] = useState(false) const [laeuft, setLaeuft] = useState(false) const [fortschritt, setFortschritt] = useState(0) const [ergebnisse, setErgebnisse] = useState(null) const feld = useRef(null) + // Wechselt der Ordner in der Galerie, wechselt das Ziel mit. Sonst lädt + // jemand nach einem Ordnerwechsel weiter in den vorherigen. + useEffect(() => setZiel(ordner), [ordner]) + + async function ordnerAnlegen() { + const name = (neu ?? '').trim() + if (!name) return + const pfad = ziel ? `${ziel}/${name}` : name + const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`) + if (r) { + setNeu(null) + setZiel(r.pfad) + // Damit der neue Ordner auch in der Liste steht -- sonst zeigt die + // Auswahl gleich darauf einen Namen, den sie nicht kennt. + fertig() + } + } + async function schicken(dateien: FileList | File[]) { const liste = [...dateien] if (liste.length === 0) return @@ -33,7 +68,7 @@ export function Hochladen({ ordner, fertig }: { ordner: string; fertig: () => vo const form = new FormData() for (const d of liste) form.append('file', d) - if (ordner) form.append('folder', ordner) + if (ziel) form.append('folder', ziel) try { // XMLHttpRequest und nicht fetch: nur der kennt upload.onprogress. @@ -78,6 +113,66 @@ export function Hochladen({ ordner, fertig }: { ordner: string; fertig: () => vo return (
+ {/* Die Ordnerwahl STEHT ÜBER dem Ablegefeld und nicht darunter: + sie ist die Entscheidung, die man VOR dem Ziehen trifft. */} +
+ + + +
+ + {neu !== null && ( +
+ + {ziel ? `${ziel}/` : ''} + + setNeu(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void ordnerAnlegen() + else if (e.key === 'Escape') setNeu(null) + }} + placeholder="name" + className="min-w-0 flex-1 rounded-md border border-rand bg-grund px-2 py-1.5 + font-mono text-xs text-schrift outline-none focus:border-akzent" + /> + void ordnerAnlegen()} + kind="Anlegen" + /> +
+ )} +
{ e.preventDefault() @@ -102,9 +197,8 @@ export function Hochladen({ ordner, fertig }: { ordner: string; fertig: () => vo Dateien hierher ziehen oder auswählen - Ziel:{' '} - {ordner ? `${ordner}/` : '(Wurzel)'} · - Bilder, Videos, Ton, PDF + nach {ziel ? `${ziel}/` : '(Wurzel)'} · + Bilder, Videos, Ton, PDF, Text — ein ZIP wird ausgepackt
diff --git a/server/ui/src/components/Ordner.tsx b/server/ui/src/components/Ordner.tsx index c8cbe57..8028088 100644 --- a/server/ui/src/components/Ordner.tsx +++ b/server/ui/src/components/Ordner.tsx @@ -1,24 +1,54 @@ -import { Folder, FolderOpen, Layers } from 'lucide-react' -import type { Folders } from '../api' +import { useState } from 'react' +import { Check, Folder, FolderOpen, FolderPlus, Layers, X } from 'lucide-react' +import { api, type Folders } from '../api' import { bytes } from '../format' +import { run } from '../store' /** Die Ordnerübersicht neben der Galerie. * * Kein aufklappbarer Baum, sondern eine flache Liste mit Einrückung. Bei * einer Handvoll Ordner ist ein Aufklappmechanismus mehr Bedienung als * Nutzen — und was man nicht aufklappen muss, kann man auch nicht zugeklappt - * übersehen. */ + * übersehen. + * + * HIER STEHT AUCH "NEUER ORDNER", und das ist eine Korrektur: anlegen ging + * bisher NUR im Verschieben-Dialog. Wer einen leeren Ordner wollte, musste + * also erst Dateien auswählen, „Verschieben" drücken und dort das Feld + * finden — eine Funktion, die man nur über eine andere Funktion erreicht, ist + * für den, der sie sucht, nicht vorhanden. */ export function Ordner({ daten, gewaehlt, onWahl, + onAngelegt, }: { daten: Folders | null gewaehlt: string onWahl: (ordner: string) => void + /** Damit die Liste den neuen Ordner auch zeigt. */ + onAngelegt: () => void }) { + const [neu, setNeu] = useState(null) + const [legtAn, setLegtAn] = useState(false) + if (!daten) return null + async function anlegen() { + const name = (neu ?? '').trim() + if (!name) return + setLegtAn(true) + // Relativ zum GEWÄHLTEN Ordner. Wer in "items" steht und "waffen" + // tippt, meint items/waffen -- alles andere wäre eine Überraschung. + const pfad = gewaehlt ? `${gewaehlt}/${name}` : name + const r = await run(() => api.ordnerAnlegen(pfad), (x) => `Ordner ${x.pfad} angelegt.`) + setLegtAn(false) + if (r) { + setNeu(null) + onAngelegt() + onWahl(r.pfad) + } + } + const zeile = ( aktiv: boolean, kind: React.ReactNode, @@ -86,6 +116,64 @@ export function Ordner({ sehen unter „Alle".

)} + +
+ {neu === null ? ( + + ) : ( +
+ {/* WO er entsteht, steht dabei. Ohne diese Zeile tippt + jemand "waffen", landet in "items/waffen" und sucht + danach an der Wurzel. */} +

+ in{' '} + {gewaehlt || '(Wurzel)'} +

+
+ setNeu(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void anlegen() + else if (e.key === 'Escape') setNeu(null) + }} + placeholder="name" + className="min-w-0 flex-1 rounded border border-rand bg-grund px-2 py-1 + font-mono text-xs text-schrift outline-none + focus:border-akzent" + /> + + +
+

+ Buchstabe oder Ziffer zuerst, danach auch Punkt, Strich, Unterstrich. +

+
+ )} +
) } diff --git a/server/ui/src/format.ts b/server/ui/src/format.ts index 837c10c..f9eec13 100644 --- a/server/ui/src/format.ts +++ b/server/ui/src/format.ts @@ -25,3 +25,16 @@ export function zeit(ms: number | null): string { } export const istBild = (mime: string) => mime.startsWith('image/') + +/** Sekunden als 1:07 oder 1:02:03. + * + * Stunden nur, wenn es welche gibt: "0:01:07" auf einer Kachel ist drei + * Zeichen Laerm fuer eine Auskunft, die niemand gestellt hat. */ +export function dauerText(sekunden: number): string { + const ganz = Math.round(sekunden) + const s = ganz % 60 + const m = Math.floor(ganz / 60) % 60 + const h = Math.floor(ganz / 3600) + const zwei = (n: number) => String(n).padStart(2, '0') + return h > 0 ? `${h}:${zwei(m)}:${zwei(s)}` : `${m}:${zwei(s)}` +} diff --git a/server/ui/src/pages/Galerie.tsx b/server/ui/src/pages/Galerie.tsx index b4be9e3..1544dc0 100644 --- a/server/ui/src/pages/Galerie.tsx +++ b/server/ui/src/pages/Galerie.tsx @@ -14,6 +14,7 @@ import { Music, ImageOff, Loader2, + Play, RefreshCw, Search, Trash2, @@ -31,7 +32,7 @@ import { type Sortierung, } from '../api' import { run, useStore } from '../store' -import { bytes, istBild, zeit } from '../format' +import { bytes, dauerText, istBild, zeit } from '../format' import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine' import { Ordner } from '../components/Ordner' import { Hochladen } from '../components/Hochladen' @@ -238,6 +239,7 @@ export function Galerie() { void ordnerLaden()} onWahl={(o) => { setOrdner(o) setSeite(0) @@ -442,6 +444,7 @@ export function Galerie() { { void laden() void ordnerLaden() @@ -486,32 +489,37 @@ export function Galerie() { className="block w-full" aria-label={`${item.path} ansehen`} > -
- {istBild(item.mime) ? ( - { - const bild = e.currentTarget - if (bild.dataset.gefallen) return - bild.dataset.gefallen = 'ja' - bild.src = item.url - }} - alt={item.path} - loading="lazy" - className="h-full w-full object-contain" - /> - ) : ( - +
+ + + {/* Das Abspielzeichen liegt ÜBER dem + Standbild. Ohne es sieht ein Video + aus wie ein Bild, und man klickt es + in der Erwartung an, ein Bild zu + sehen. */} + {(item.mime.startsWith('video') || + item.mime.startsWith('audio')) && ( + + + + + + )} + + {item.dauer !== null && item.dauer > 0 && ( + + {dauerText(item.dauer)} + )}
@@ -680,3 +688,65 @@ export function Galerie() {
) } + +/** Was auf der Kachel steht. + * + * ERST DIE VORSCHAU, immer: 60 Vollbilder sind 7,2 MB, nur damit jemand sieht, + * WELCHE Dateien da sind. Video und Ton haben seit ffmpeg ebenfalls eine — + * ein Standbild aus dem Clip, bei einer MP3 das eingebettete Titelbild. + * + * FEHLT SIE, wird nicht geraten. Bei einem Bild nimmt der Browser über + * `onError` das Vollbild; bei allem anderen kommt ein Zeichen, das die ART + * benennt. Vorher stand dort für jede Nicht-Bild-Datei ein Fragezeichen — + * also ausgerechnet das Zeichen für „unbekannt" auf einer völlig gesunden + * MP4. */ +function Vorschau({ item }: { item: MediaItem }) { + const [gescheitert, setGescheitert] = useState(false) + const bewegt = item.mime.startsWith('video') || item.mime.startsWith('audio') + + // Sobald die Datei wechselt, ist ein früherer Fehlschlag hinfällig. + useEffect(() => setGescheitert(false), [item.id]) + + if (!gescheitert) { + return ( + { + // Bei einem Bild lohnt der zweite Versuch mit dem Vollbild: + // die Vorschau ist abgeleitet und kann fehlen, das Bild + // selbst liegt da. Bei Video und Ton wäre derselbe Griff + // grober Unfug — er lüde den ganzen Clip in ein . + const bild = e.currentTarget + if (istBild(item.mime) && !bild.dataset.gefallen) { + bild.dataset.gefallen = 'ja' + bild.src = item.url + return + } + setGescheitert(true) + }} + alt={item.path} + loading="lazy" + className="h-full w-full object-contain" + /> + ) + } + + const Zeichen = item.mime.startsWith('video') + ? Film + : item.mime.startsWith('audio') + ? Music + : item.mime === 'application/pdf' || + item.mime.startsWith('text/') || + item.mime.startsWith('application/json') + ? FileText + : istBild(item.mime) + ? ImageOff + : FileQuestion + + return ( + + ) +} diff --git a/server/ui/src/pages/Speicher.tsx b/server/ui/src/pages/Speicher.tsx index 4c3e283..639f415 100644 --- a/server/ui/src/pages/Speicher.tsx +++ b/server/ui/src/pages/Speicher.tsx @@ -163,8 +163,9 @@ export function Speicher() { {fortschritt.thumbs} ) : (

- Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion - hochgeladen wurde, hat noch keine. + Die Galerie lädt Vorschauen statt Vollbilder. Bei Video ist es ein + Standbild aus dem Clip, bei Ton das eingebettete Titelbild — und + dass die meisten MP3 keines haben, ist kein Fehler.

)}