diff --git a/server/api/notifications/index.get.ts b/server/api/notifications/index.get.ts index d5a1e76..c987f34 100644 --- a/server/api/notifications/index.get.ts +++ b/server/api/notifications/index.get.ts @@ -42,9 +42,8 @@ export default defineEventHandler(async (event) => { notifications: rows.map((row) => { const url = row.externalUrl ?? `/uploads/${row.fileName}` const embedId = row.uploadType === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null - const thumbUrl = row.uploadType === 'youtube' && embedId - ? youtubeThumbUrl(embedId) - : row.uploadType === 'image' ? url : row.uploadType === 'link' ? row.previewImage : null + const thumbUrl = row.previewImage + ?? (row.uploadType === 'youtube' && embedId ? youtubeThumbUrl(embedId) : row.uploadType === 'image' ? url : null) return { ...row, url, thumbUrl, isMention: mentionedUserIds(row.content, mentionUsers).has(user.id) } }), unreadCount: Number(total?.count ?? 0), diff --git a/server/api/uploads/[id].delete.ts b/server/api/uploads/[id].delete.ts index f1e1d58..76b283b 100644 --- a/server/api/uploads/[id].delete.ts +++ b/server/api/uploads/[id].delete.ts @@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => { const db = useDb() const [upload] = await db - .select({ fileName: uploads.fileName }) + .select({ fileName: uploads.fileName, previewImage: uploads.previewImage }) .from(uploads) .where(eq(uploads.id, id)) .limit(1) @@ -30,11 +30,15 @@ export default defineEventHandler(async (event) => { if (upload.fileName) { await unlink(join(uploadDir(), upload.fileName)).catch(() => {}) } + if (upload.previewImage?.startsWith('/uploads/')) { + await unlink(join(process.cwd(), 'public', upload.previewImage)).catch(() => {}) + } for (const f of attachmentFiles) { await unlink(join(uploadDir(), 'attachments', f.fileName)).catch(() => {}) if (f.cover) { await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {}) } } + broadcastToAll({ type: 'upload-deleted', ids: [id] }) return { ok: true } }) diff --git a/server/api/uploads/[id]/comments.post.ts b/server/api/uploads/[id]/comments.post.ts index dd25a6c..b67ec78 100644 --- a/server/api/uploads/[id]/comments.post.ts +++ b/server/api/uploads/[id]/comments.post.ts @@ -3,7 +3,7 @@ import { extname, join } from 'node:path' import { eq, sql } from 'drizzle-orm' import { z } from 'zod' import { commentAttachments, comments, uploads } from '../../../db/schema' -import { isAudioAttachment, MAX_COMMENT_ATTACHMENTS } from '~~/shared/utils/attachments' +import { isAudioAttachment, isVideoAttachment, MAX_COMMENT_ATTACHMENTS } from '~~/shared/utils/attachments' const bodySchema = z.object({ content: z.string().trim().min(1).max(2000), @@ -82,6 +82,9 @@ export default defineEventHandler(async (event) => { const audioMeta = !file.voice && isAudioAttachment({ mimeType, name: filename }) ? await extractAudioMeta(`att-${commentId}-${i}`, data, mimeType) : null + const videoThumb = !file.voice && isVideoAttachment({ mimeType, name: filename }) + ? await extractVideoThumb(`att-${commentId}-${i}`, join(attachmentsDir(), storedName)) + : null const meta = file.voice ? { voice: true } : (audioMeta?.meta ?? null) const [row] = await db.insert(commentAttachments).values({ commentId, @@ -89,7 +92,7 @@ export default defineEventHandler(async (event) => { originalName: filename.slice(0, 255), mimeType, size: data.length, - cover: audioMeta?.cover ?? null, + cover: videoThumb ?? audioMeta?.cover ?? null, meta, }) attachments.push({ @@ -98,7 +101,7 @@ export default defineEventHandler(async (event) => { url: `/uploads/attachments/${storedName}`, mimeType, size: data.length, - cover: audioMeta?.cover ?? null, + cover: videoThumb ?? audioMeta?.cover ?? null, meta, }) } diff --git a/server/api/uploads/index.post.ts b/server/api/uploads/index.post.ts index 25f2af1..cac3222 100644 --- a/server/api/uploads/index.post.ts +++ b/server/api/uploads/index.post.ts @@ -64,6 +64,9 @@ export default defineEventHandler(async (event) => { const audioMeta = media.type === 'audio' ? await extractAudioMeta(id, media.data, media.mimeType) : null + const videoThumb = media.type === 'video' + ? await extractVideoThumb(id, join(uploadDir(), fileName)) + : null await db.insert(uploads).values({ id, userId: user.id, @@ -72,7 +75,7 @@ export default defineEventHandler(async (event) => { originalName: decodeURIComponent(basename(new URL(body.url).pathname) || body.url).slice(0, 255), mimeType: media.mimeType.slice(0, 100), size: media.data.length, - previewImage: audioMeta?.cover ?? null, + previewImage: videoThumb ?? audioMeta?.cover ?? null, audioMeta: audioMeta?.meta ?? null, }) await ensureThreadSubscription(id, user.id) @@ -84,7 +87,9 @@ export default defineEventHandler(async (event) => { type === 'link' || type === 'twitch' ? fetchLinkPreview(body.url) : null, twitchTarget ? fetchTwitchPreview(twitchTarget) : null, ]) - const remotePreviewImage = twitchPreview?.image ?? preview?.image ?? null + const embedId = type === 'youtube' ? getYoutubeId(body.url) : null + const remotePreviewImage = twitchPreview?.image ?? preview?.image + ?? (embedId ? youtubeThumbUrl(embedId) : null) const previewImage = remotePreviewImage ? (await savePreviewImage(id, remotePreviewImage)) ?? remotePreviewImage : null @@ -137,6 +142,9 @@ export default defineEventHandler(async (event) => { const audioMeta = type === 'audio' ? await extractAudioMeta(id, file.data, mimeType) : null + const videoThumb = type === 'video' + ? await extractVideoThumb(id, join(uploadDir(), fileName)) + : null await db.insert(uploads).values({ id, userId: user.id, @@ -145,7 +153,7 @@ export default defineEventHandler(async (event) => { originalName: (file.filename ?? fileName).slice(0, 255), mimeType: (mimeType || 'application/octet-stream').slice(0, 100), size: file.data.length, - previewImage: audioMeta?.cover ?? null, + previewImage: videoThumb ?? audioMeta?.cover ?? null, audioMeta: audioMeta?.meta ?? null, }) diff --git a/server/api/uploads/meta.get.ts b/server/api/uploads/meta.get.ts index 80b9cb4..51f6f20 100644 --- a/server/api/uploads/meta.get.ts +++ b/server/api/uploads/meta.get.ts @@ -25,9 +25,8 @@ export default defineEventHandler(async (event) => { return rows.map((row) => { const url = row.externalUrl ?? `/uploads/${row.fileName}` const embedId = row.type === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null - const thumbUrl = row.type === 'youtube' && embedId - ? youtubeThumbUrl(embedId) - : row.type === 'image' ? url : row.type === 'link' || row.type === 'twitch' || row.type === 'audio' ? row.previewImage : null + const thumbUrl = row.previewImage + ?? (row.type === 'youtube' && embedId ? youtubeThumbUrl(embedId) : row.type === 'image' ? url : null) return { id: row.id, thumbUrl } }) }) diff --git a/server/api/users/[id]/uploads.get.ts b/server/api/users/[id]/uploads.get.ts index 6ab0b9b..0cd8a74 100644 --- a/server/api/users/[id]/uploads.get.ts +++ b/server/api/users/[id]/uploads.get.ts @@ -37,9 +37,8 @@ export default defineEventHandler(async (event) => { const items = (pages[page - 1] ?? []).map((row) => { const url = row.externalUrl ?? `/uploads/${row.fileName}` const embedId = row.type === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null - const thumbUrl = row.type === 'youtube' && embedId - ? youtubeThumbUrl(embedId) - : row.type === 'image' ? url : row.type === 'link' || row.type === 'twitch' || row.type === 'audio' ? row.previewImage : null + const thumbUrl = row.previewImage + ?? (row.type === 'youtube' && embedId ? youtubeThumbUrl(embedId) : row.type === 'image' ? url : null) return { ...row, url, thumbUrl, userAvatarUrl: avatarUrl(row.userAvatar) } }) diff --git a/server/utils/uploads.ts b/server/utils/uploads.ts index 7fac5d0..81305f3 100644 --- a/server/utils/uploads.ts +++ b/server/utils/uploads.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process' import { randomInt } from 'node:crypto' -import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' +import { access, mkdir, readFile, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { eq } from 'drizzle-orm' @@ -192,6 +192,36 @@ export async function extractAudioMeta(uploadId: string, data: Buffer, mimeType: } } +// Video-Vorschaubild: Frame bei ~1s per ffmpeg als JPG in thumbs/ ablegen. +// Bei sehr kurzen Videos liefert -ss 1 kein Frame — dann ohne Seek nochmal. +export async function extractVideoThumb(key: string, videoPath: string): Promise { + if (!ffmpegPath || !/^[A-Za-z0-9/_-]+$/.test(key)) return null + const outPath = join(uploadDir(), 'thumbs', `${key}.jpg`) + const run = (seek: boolean) => new Promise((resolve, reject) => { + const proc = spawn(ffmpegPath!, [ + '-hide_banner', '-loglevel', 'error', '-y', + ...(seek ? ['-ss', '1'] : []), + '-i', videoPath, + '-frames:v', '1', + '-vf', 'scale=480:-2', + '-q:v', '5', + outPath, + ]) + proc.on('error', reject) + proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)))) + }) + const exists = () => access(outPath).then(() => true, () => false) + try { + await mkdir(dirname(outPath), { recursive: true }) + await run(true) + if (!(await exists())) await run(false) + return (await exists()) ? `/uploads/thumbs/${key}.jpg` : null + } + catch { + return null + } +} + // Sprachnachrichten kommen als WebM/Opus o.ä. rein — MediaRecorder kann selbst // kein MP3 encodieren, daher serverseitig per ffmpeg nach MP3 transkodieren. export async function transcodeVoiceToMp3(data: Buffer): Promise {