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
+2 -2
View File
@@ -589,9 +589,9 @@ export const journeyApi = {
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
@@ -217,10 +217,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
for (const group of groups) {
try {
if (entryId) {
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase, group.mediaTypes)
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase, group.mediaTypes)
added += result.added || 0
}
} catch {
@@ -13,7 +13,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
trips: JourneyTrip[]
existingAssetIds: Set<string>
onClose: () => void
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise<void>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
@@ -27,7 +27,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
@@ -123,7 +123,8 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (next.has(id)) {
next.delete(id)
} else {
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
}
return next
})
@@ -293,7 +294,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
if (allSelected) {
setSelected(new Map())
} else {
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -396,13 +397,14 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
</button>
<button
onClick={() => {
const groupMap = new Map<string | undefined, string[]>()
for (const [assetId, { passphrase }] of selected.entries()) {
const list = groupMap.get(passphrase) || []
list.push(assetId)
groupMap.set(passphrase, list)
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
g.assetIds.push(assetId)
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
groupMap.set(passphrase, g)
}
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
onAdd(groups, targetEntryId)
}}
disabled={selected.size === 0}
+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': {
@@ -164,7 +164,7 @@ describe('JourneyController', () => {
it('provider-photos batch passes the passphrase through when present', () => {
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
new JourneyController(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret');
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image');
// single-photo success path
expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
@@ -191,7 +191,7 @@ describe('JourneyController', () => {
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial<JourneyService>));
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw');
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image');
expect(thrown(() => new JourneyController(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
+3
View File
@@ -42,6 +42,9 @@ export const journeyProviderPhotosRequestSchema = z.object({
asset_ids: z.array(z.union([z.string(), z.number()])).optional(),
caption: z.string().optional(),
passphrase: z.string().optional(),
// Per-asset 'image' | 'video' discriminator, parallel to asset_ids (#823).
media_type: z.string().optional(),
media_types: z.array(z.string()).optional(),
});
export type JourneyProviderPhotosRequest = z.infer<typeof journeyProviderPhotosRequestSchema>;