feat(video): media_type discriminator + local gallery video upload (server)

trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.
This commit is contained in:
Maurice
2026-06-30 10:31:23 +02:00
committed by Maurice
parent c7e4b2781b
commit 993d9bf713
7 changed files with 120 additions and 12 deletions
+6
View File
@@ -56,6 +56,9 @@ export interface JourneyPhoto {
thumbnail_path?: string | null
width?: number | null
height?: number | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
}
export interface GalleryPhoto {
@@ -74,6 +77,9 @@ export interface GalleryPhoto {
thumbnail_path?: string | null
width?: number | null
height?: number | null
// 'image' (default) or 'video' (#823)
media_type?: string | null
duration_ms?: number | null
}
export interface JourneyTrip {
+16
View File
@@ -3107,6 +3107,22 @@ function runMigrations(db: Database.Database): void {
}
db.exec('UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL');
},
// Video support (#823): the trek_photos registry held only images. media_type
// discriminates image vs video so the gallery, lightbox and provider proxy can
// branch; duration_ms is optional metadata for the player. Additive — existing
// rows default to 'image'.
() => {
for (const stmt of [
"ALTER TABLE trek_photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image'",
'ALTER TABLE trek_photos ADD COLUMN duration_ms INTEGER',
]) {
try {
db.exec(stmt);
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
}
},
];
if (currentVersion < migrations.length) {
+55 -2
View File
@@ -15,7 +15,7 @@ import {
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { FileInterceptor, FilesInterceptor, FileFieldsInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import path from 'node:path';
import fs from 'node:fs';
@@ -25,7 +25,7 @@ import { JourneyService } from './journey.service';
import { JourneyAddonGuard } from './journey-addon.guard';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getAllowedExtensions } from '../../services/fileService';
import { getAllowedExtensions, isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../../services/fileService';
const uploadsBase = path.join(__dirname, '../../../uploads/journey');
const IMAGE_UPLOAD = {
@@ -51,6 +51,33 @@ const IMAGE_UPLOAD = {
},
};
// Gallery video upload (#823): one video plus an optional client-captured poster
// image, written to the same uploads/journey store. Larger cap than images since
// phone clips are big; videos are stored as-is and streamed with HTTP Range.
const VIDEO_UPLOAD = {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || (file.fieldname === 'poster' ? '.jpg' : '.mp4')}`),
}),
limits: { fileSize: MAX_VIDEO_SIZE },
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
const reject = (msg: string) => {
const err: Error & { statusCode?: number } = new Error(msg);
err.statusCode = 400;
cb(err, false);
};
if (file.fieldname === 'poster') {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) return reject('Poster must be an image');
return cb(null, true);
}
// 'video' field
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
if (!isVideoMime(file.mimetype)) return reject('Only video files are allowed');
if (!isVideoExtension(ext)) return reject(`Video type .${ext} is not allowed`);
cb(null, true);
},
};
/**
* /api/journeys — cross-trip travel narrative (journeys, entries, photo gallery
* + provider mirroring, contributors, preferences, share links).
@@ -222,6 +249,32 @@ export class JourneyController {
return { photos };
}
@Post(':id/gallery/video')
@UseInterceptors(FileFieldsInterceptor([{ name: 'video', maxCount: 1 }, { name: 'poster', maxCount: 1 }], VIDEO_UPLOAD))
uploadGalleryVideo(
@CurrentUser() user: User,
@Param('id') id: string,
@UploadedFiles() files: { video?: Express.Multer.File[]; poster?: Express.Multer.File[] } | undefined,
@Body() body: { duration_ms?: string },
) {
const video = files?.video?.[0];
if (!video) {
throw new HttpException({ error: 'No video uploaded' }, 400);
}
const poster = files?.poster?.[0];
const durationMs = body?.duration_ms != null ? Number(body.duration_ms) : null;
const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, [{
path: `journey/${video.filename}`,
thumbnail: poster ? `journey/${poster.filename}` : undefined,
mediaType: 'video',
durationMs: durationMs != null && Number.isFinite(durationMs) ? durationMs : null,
}]);
if (!photos.length) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { photos };
}
@Post(':id/gallery/provider-photos')
galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; passphrase?: string }) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
+15
View File
@@ -12,6 +12,21 @@ import { TripFile } from '../types';
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass';
// Video support (#823). Gallery/media uploads accept these in addition to images,
// independent of the admin doc-types allowlist. Videos are stored as-is and
// streamed with HTTP Range; the cap is higher than images because phone clips are
// large.
export const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov'];
export const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
export function isVideoMime(mime: string | undefined | null): boolean {
return !!mime && mime.startsWith('video/');
}
export function isVideoExtension(ext: string): boolean {
return VIDEO_EXTENSIONS.includes(ext.toLowerCase().replace(/^\./, ''));
}
// Single authoritative blocklist for every file-upload surface (main
// file manager + collab attachments). When the admin setting
// `allowed_file_types` is `*`, this list is still enforced so the
+6 -4
View File
@@ -16,7 +16,8 @@ function ts(): number {
// id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete.
const JP_SELECT = `
gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
tp.media_type, tp.duration_ms
`;
const JP_JOIN = `journey_entry_photos jep
JOIN journey_photos gp ON gp.id = jep.journey_photo_id
@@ -25,7 +26,8 @@ const JP_JOIN = `journey_entry_photos jep
// Per-journey gallery view: journey_photos → trek_photos (no entry context).
const GALLERY_SELECT = `
gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height,
tp.media_type, tp.duration_ms
`;
const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
@@ -918,7 +920,7 @@ export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId
export function uploadGalleryPhotos(
journeyId: number,
userId: number,
filePaths: { path: string; thumbnail?: string }[],
filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[],
): JourneyPhoto[] {
if (!canEdit(journeyId, userId)) return [];
const results: any[] = [];
@@ -929,7 +931,7 @@ export function uploadGalleryPhotos(
let nextOrder = (maxOrderRow?.m ?? -1) + 1;
for (const f of filePaths) {
const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail);
const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null);
db.prepare(
`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
@@ -18,6 +18,7 @@ export function getOrCreateTrekPhoto(
assetId: string,
ownerId: number,
passphrase?: string,
mediaType: string = 'image',
): number {
const existing = db.prepare(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
@@ -31,8 +32,8 @@ export function getOrCreateTrekPhoto(
}
const res = db.prepare(
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)'
).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null);
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type) VALUES (?, ?, ?, ?, ?)'
).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null, mediaType);
return Number(res.lastInsertRowid);
}
@@ -41,6 +42,8 @@ export function getOrCreateLocalTrekPhoto(
thumbnailPath?: string | null,
width?: number | null,
height?: number | null,
mediaType: string = 'image',
durationMs?: number | null,
): number {
const existing = db.prepare(
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?"
@@ -48,8 +51,8 @@ export function getOrCreateLocalTrekPhoto(
if (existing) return existing.id;
const res = db.prepare(
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)'
).run('local', filePath, thumbnailPath || null, width || null, height || null);
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run('local', filePath, thumbnailPath || null, width || null, height || null, mediaType, durationMs ?? null);
return Number(res.lastInsertRowid);
}
@@ -105,8 +108,11 @@ export async function streamPhoto(
const uploadsRoot = path.join(__dirname, '../../../uploads');
if (kind === 'thumbnail') {
const isVideo = photo.media_type === 'video';
let thumbRel = photo.thumbnail_path ?? null;
if (!thumbRel) {
// Only raster images get a lazily-generated Jimp thumbnail; Jimp can't decode
// video, so a video relies on the poster captured at upload (#823).
if (!thumbRel && !isVideo) {
const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path);
if (result) {
thumbRel = result.thumbnailRelPath;
@@ -123,7 +129,13 @@ export async function streamPhoto(
return;
}
}
// Fall through to original if thumbnail unavailable.
// A poster-less video must NOT fall through to streaming the whole file as a
// "thumbnail"; let the client render its own placeholder instead.
if (isVideo) {
res.status(404).json({ error: 'No poster available' });
return;
}
// Images fall through to original if the thumbnail is unavailable.
}
const localPath = path.join(uploadsRoot, photo.file_path);
+4
View File
@@ -387,6 +387,10 @@ export interface TrekPhoto {
width?: number | null;
height?: number | null;
passphrase?: string | null;
/** 'image' (default) or 'video' — discriminates how the asset is served/played (#823). */
media_type?: string | null;
/** Optional video duration in milliseconds. */
duration_ms?: number | null;
created_at: string;
}