mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user