From 1abc9b2bc7a67c0123ca7bb6c0e1e88821dfd933 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 30 Jun 2026 11:05:07 +0200 Subject: [PATCH] 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. --- client/src/api/client.ts | 4 +- .../Journey/JourneyDetailPageGalleryView.tsx | 4 +- .../JourneyDetailPageProviderPicker.tsx | 22 ++++++----- server/src/nest/journey/journey.controller.ts | 22 ++++++----- server/src/nest/journey/journey.service.ts | 4 +- server/src/nest/photos/photos.controller.ts | 6 +-- server/src/nest/photos/photos.service.ts | 4 +- server/src/services/journeyService.ts | 6 ++- .../src/services/memories/helpersService.ts | 4 ++ server/src/services/memories/immichService.ts | 37 +++++++++++++++---- .../services/memories/photoResolverService.ts | 5 ++- .../unit/nest/journey.controller.test.ts | 4 +- shared/src/journey/journey.schema.ts | 3 ++ 13 files changed, 80 insertions(+), 45 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 865fd83f5..2b98c6f2a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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), diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx index ad33fda62..61ea781b2 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx @@ -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 { diff --git a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx index 92d7cf0ed..bc318aa0a 100644 --- a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx @@ -13,7 +13,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset trips: JourneyTrip[] existingAssetIds: Set onClose: () => void - onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise + onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise }) { 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>(new Map()) + const [selected, setSelected] = useState>(new Map()) const [customFrom, setCustomFrom] = useState('') const [customTo, setCustomTo] = useState('') const [targetEntryId, setTargetEntryId] = useState(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