feat(video): link and stream Immich videos in the journey gallery

Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.

Adds media_type/media_types to the provider-photos request contract.
This commit is contained in:
Maurice
2026-06-30 11:05:07 +02:00
committed by Maurice
parent 8713443665
commit 1abc9b2bc7
13 changed files with 80 additions and 45 deletions
+12 -10
View File
@@ -172,20 +172,21 @@ export class JourneyController {
}
@Post('entries/:entryId/provider-photos')
providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; caption?: string; passphrase?: string }) {
providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; caption?: string; passphrase?: string; media_type?: string; media_types?: unknown[] }) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
for (const id of body.asset_ids) {
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, String(id), body.caption, pp);
body.asset_ids.forEach((id, i) => {
const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image';
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider!, String(id), body.caption, pp, mt);
if (photo) added.push(photo);
}
});
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, body.asset_id, body.caption, pp);
const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, body.asset_id, body.caption, pp, body.media_type === 'video' ? 'video' : 'image');
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
@@ -276,20 +277,21 @@ export class JourneyController {
}
@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 }) {
galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; passphrase?: string; media_type?: string; media_types?: unknown[] }) {
const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined;
if (Array.isArray(body.asset_ids) && body.provider) {
const added: unknown[] = [];
for (const aid of body.asset_ids) {
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, String(aid), undefined, pp);
body.asset_ids.forEach((aid, i) => {
const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image';
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider!, String(aid), undefined, pp, mt);
if (photo) added.push(photo);
}
});
return { photos: added, added: added.length };
}
if (!body.provider || !body.asset_id) {
throw new HttpException({ error: 'provider and asset_id required' }, 400);
}
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, body.asset_id, undefined, pp);
const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, body.asset_id, undefined, pp, body.media_type === 'video' ? 'video' : 'image');
if (!photo) {
throw new HttpException({ error: 'Not allowed or duplicate' }, 403);
}
+2 -2
View File
@@ -46,13 +46,13 @@ export class JourneyService {
// Photos
addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath: string | undefined, caption: string | undefined) { return svc.addPhoto(entryId, userId, filePath, thumbnailPath, caption); }
setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { return svc.setPhotoProvider(photoId, provider, assetId, ownerId); }
addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string) { return svc.addProviderPhoto(entryId, userId, provider, assetId, caption, passphrase); }
addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string, mediaType?: string) { return svc.addProviderPhoto(entryId, userId, provider, assetId, caption, passphrase, mediaType); }
linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number) { return svc.linkPhotoToEntry(entryId, journeyPhotoId, userId); }
unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number) { return svc.unlinkPhotoFromEntry(entryId, journeyPhotoId, userId); }
updatePhoto(photoId: number, userId: number, data: Parameters<typeof svc.updatePhoto>[2]) { return svc.updatePhoto(photoId, userId, data); }
deletePhoto(photoId: number, userId: number) { return svc.deletePhoto(photoId, userId); }
uploadGalleryPhotos(id: number, userId: number, filePaths: Parameters<typeof svc.uploadGalleryPhotos>[2]) { return svc.uploadGalleryPhotos(id, userId, filePaths); }
addProviderPhotoToGallery(id: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string) { return svc.addProviderPhotoToGallery(id, userId, provider, assetId, caption, passphrase); }
addProviderPhotoToGallery(id: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string, mediaType?: string) { return svc.addProviderPhotoToGallery(id, userId, provider, assetId, caption, passphrase, mediaType); }
deleteGalleryPhoto(journeyPhotoId: number, userId: number) { return svc.deleteGalleryPhoto(journeyPhotoId, userId); }
// Contributors
+3 -3
View File
@@ -1,4 +1,4 @@
import { Controller, Get, HttpException, Param, Res, UseGuards } from '@nestjs/common';
import { Controller, Get, Headers, HttpException, Param, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import type { User } from '../../types';
import { PhotosService } from './photos.service';
@@ -37,9 +37,9 @@ export class PhotosController {
}
@Get(':id/original')
async original(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response): Promise<void> {
async original(@CurrentUser() user: User, @Param('id') id: string, @Res() res: Response, @Headers('range') range?: string): Promise<void> {
const photoId = this.requireAccess(user, id);
await this.photos.stream(res, user.id, photoId, 'original');
await this.photos.stream(res, user.id, photoId, 'original', range);
}
@Get(':id/info')
+2 -2
View File
@@ -14,8 +14,8 @@ export class PhotosService {
return canAccessTrekPhoto(userId, photoId);
}
stream(res: Response, userId: number, photoId: number, kind: 'thumbnail' | 'original') {
return streamPhoto(res, userId, photoId, kind);
stream(res: Response, userId: number, photoId: number, kind: 'thumbnail' | 'original', range?: string) {
return streamPhoto(res, userId, photoId, kind, range);
}
info(userId: number, photoId: number) {
+4 -2
View File
@@ -874,12 +874,13 @@ export function addProviderPhoto(
assetId: string,
caption?: string,
passphrase?: string,
mediaType: string = 'image',
): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase, mediaType);
// skip if this photo is already linked to this entry
const alreadyLinked = db
@@ -954,9 +955,10 @@ export function addProviderPhotoToGallery(
assetId: string,
caption?: string,
passphrase?: string,
mediaType: string = 'image',
): any | null {
if (!canEdit(journeyId, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase, mediaType);
const galleryId = db.transaction(() => ensureInGallery(journeyId, trekPhotoId, caption))();
return db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null;
}
@@ -266,6 +266,10 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
}
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
// Pass byte-range metadata through so a <video> can seek (#823). Upstream
// returns 206 + Content-Range when the caller forwarded a Range header.
if (resp.headers.get('accept-ranges')) response.set('Accept-Ranges', resp.headers.get('accept-ranges') as string);
if (resp.headers.get('content-range')) response.set('Content-Range', resp.headers.get('content-range') as string);
if (!resp.body) {
response.end();
+29 -8
View File
@@ -169,7 +169,7 @@ export async function searchPhotos(
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
// No type filter — surface videos alongside images (#823).
size,
page,
}),
@@ -183,6 +183,7 @@ export async function searchPhotos(
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets, hasMore: items.length >= size };
} catch {
@@ -265,18 +266,37 @@ export async function streamImmichAsset(
userId: number,
assetId: string,
kind: 'thumbnail' | 'original',
ownerUserId?: number
ownerUserId?: number,
opts?: { mediaType?: string | null; range?: string },
): Promise<{ error?: string; status?: number } | void> {
const effectiveUserId = ownerUserId ?? userId;
const creds = getImmichCredentials(effectiveUserId);
if (!creds) return { error: 'Not found', status: 404 };
const timeout = kind === 'thumbnail' ? 10000 : 30000;
const url = kind === 'thumbnail'
? `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`
: `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
const isVideo = opts?.mediaType === 'video';
const headers: Record<string, string> = { 'x-api-key': creds.immich_api_key };
let url: string;
let timeout: number | undefined;
let cacheControl = 'public, max-age=86400';
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout), 'public, max-age=86400');
if (kind === 'thumbnail') {
// Immich generates a poster thumbnail for video too.
url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
timeout = 10000;
} else if (isVideo) {
// Transcoded, broadly-compatible MP4 with byte-range support; forward the
// viewer's Range so the player can seek. No abort timeout — video is a long
// streaming response, not a quick fetch (#823).
url = `${creds.immich_url}/api/assets/${assetId}/video/playback`;
if (opts?.range) headers['Range'] = opts.range;
cacheControl = 'private, max-age=3600';
timeout = undefined;
} else {
url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
timeout = 30000;
}
await pipeAsset(url, response, headers, timeout ? AbortSignal.timeout(timeout) : undefined, cacheControl);
}
// ── Albums ──────────────────────────────────────────────────────────────────
@@ -337,11 +357,12 @@ export async function getAlbumPhotos(
});
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE').map((a: any) => ({
const assets = (albumData.assets || []).map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
mediaType: a.type === 'VIDEO' ? 'video' : 'image',
}));
return { assets };
} catch {
@@ -97,6 +97,7 @@ export async function streamPhoto(
userId: number,
photoId: number,
kind: 'thumbnail' | 'original',
range?: string,
): Promise<void> {
const photo = resolveTrekPhoto(photoId);
if (!photo) {
@@ -156,11 +157,11 @@ export async function streamPhoto(
await streamCachedThumbnail(
res, photo,
() => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!),
() => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!),
() => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type }),
);
return;
}
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!, { mediaType: photo.media_type, range });
return;
}
case 'synologyphotos': {