Fix #15 Support live photos and HEIC images

This commit is contained in:
Alan Grainger 2024-11-12 09:48:03 +01:00
parent 6608d8ca5b
commit b5265edb6a
8 changed files with 51 additions and 29 deletions

View File

@ -4,7 +4,8 @@
"Cache-Control": "public, max-age=2592000" "Cache-Control": "public, max-age=2592000"
}, },
"singleImageGallery": false, "singleImageGallery": false,
"singleItemAutoOpen": true "singleItemAutoOpen": true,
"downloadOriginalPhoto": false
}, },
"lightGallery": { "lightGallery": {
"controls": true, "controls": true,

View File

@ -1,6 +1,6 @@
{ {
"name": "immich-public-proxy", "name": "immich-public-proxy",
"version": "1.3.10", "version": "1.4.0",
"scripts": { "scripts": {
"dev": "ts-node src/index.ts", "dev": "ts-node src/index.ts",
"build": "npx tsc", "build": "npx tsc",

View File

@ -1,6 +1,5 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Request, Response } from 'express-serve-static-core' import { Response } from 'express-serve-static-core'
import { ImageSize } from './types'
let config = {} let config = {}
try { try {
@ -31,14 +30,6 @@ export const getConfigOption = (path: string, defaultOption?: unknown) => {
*/ */
export const log = (message: string) => console.log(dayjs().format() + ' ' + message) export const log = (message: string) => console.log(dayjs().format() + ' ' + message)
/**
* Sanitise the data for an incoming query string `size` parameter
* e.g. https://example.com/share/abc...xyz?size=thumbnail
*/
export function getSize (req: Request) {
return req.query?.size === 'thumbnail' ? ImageSize.thumbnail : ImageSize.original
}
/** /**
* Force a value to be a string * Force a value to be a string
*/ */

View File

@ -63,7 +63,10 @@ class Immich {
// Password required - show the visitor the password page // Password required - show the visitor the password page
// `req.params.key` is already sanitised at this point, but it never hurts to be explicit // `req.params.key` is already sanitised at this point, but it never hurts to be explicit
const key = request.key.replace(/[^\w-]/g, '') const key = request.key.replace(/[^\w-]/g, '')
res.render('password', { key, lgConfig: render.lgConfig }) res.render('password', {
key,
lgConfig: render.lgConfig
})
} else if (sharedLinkRes.link) { } else if (sharedLinkRes.link) {
// Valid shared link // Valid shared link
const link = sharedLinkRes.link const link = sharedLinkRes.link
@ -77,7 +80,7 @@ class Immich {
const asset = link.assets[0] const asset = link.assets[0]
if (asset.type === AssetType.image && !getConfigOption('ipp.singleImageGallery')) { if (asset.type === AssetType.image && !getConfigOption('ipp.singleImageGallery')) {
// For photos, output the image directly unless configured to show a gallery // For photos, output the image directly unless configured to show a gallery
await render.assetBuffer(request, res, link.assets[0], request.size) await render.assetBuffer(request, res, link.assets[0], ImageSize.preview)
} else { } else {
// Show a gallery page // Show a gallery page
const openItem = getConfigOption('ipp.singleItemAutoOpen', true) ? 1 : 0 const openItem = getConfigOption('ipp.singleItemAutoOpen', true) ? 1 : 0
@ -175,11 +178,10 @@ class Immich {
* Return the image data URL for a photo * Return the image data URL for a photo
*/ */
photoUrl (key: string, id: string, size?: ImageSize, password?: string) { photoUrl (key: string, id: string, size?: ImageSize, password?: string) {
const params = { key, size } const path = ['photo', key, id]
if (password) { if (size) path.push(size)
Object.assign(params, this.encryptPassword(password)) const params = password ? this.encryptPassword(password) : {}
} return this.buildUrl('/' + path.join('/'), params)
return this.buildUrl(`/photo/${key}/${id}`, params)
} }
/** /**
@ -223,6 +225,14 @@ class Immich {
async accessible () { async accessible () {
return !!(await immich.request('/server/ping')) return !!(await immich.request('/server/ping'))
} }
validateImageSize (size: unknown) {
if (!size || !Object.values(ImageSize).includes(size as ImageSize)) {
return ImageSize.preview
} else {
return size as ImageSize
}
}
} }
const immich = new Immich() const immich = new Immich()

View File

@ -4,7 +4,7 @@ import render from './render'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { AssetType } from './types' import { AssetType } from './types'
import { decrypt } from './encrypt' import { decrypt } from './encrypt'
import { log, getSize, toString, addResponseHeaders } from './functions' import { log, toString, addResponseHeaders } from './functions'
require('dotenv').config() require('dotenv').config()
@ -19,8 +19,7 @@ app.use(express.static('public', { setHeaders: addResponseHeaders }))
// An incoming request for a shared link // An incoming request for a shared link
app.get('/share/:key', async (req, res) => { app.get('/share/:key', async (req, res) => {
await immich.handleShareRequest({ await immich.handleShareRequest({
key: req.params.key, key: req.params.key
size: getSize(req)
}, res) }, res)
}) })
@ -33,7 +32,7 @@ app.post('/unlock', async (req, res) => {
}) })
// Output the buffer data for a photo or video // Output the buffer data for a photo or video
app.get('/:type(photo|video)/:key/:id', async (req, res) => { app.get('/:type(photo|video)/:key/:id/:size?', async (req, res) => {
addResponseHeaders(res) addResponseHeaders(res)
// Check for valid key and ID // Check for valid key and ID
if (immich.isKey(req.params.key) && immich.isId(req.params.id)) { if (immich.isKey(req.params.key) && immich.isId(req.params.id)) {
@ -60,7 +59,7 @@ app.get('/:type(photo|video)/:key/:id', async (req, res) => {
const asset = sharedLink.assets.find(x => x.id === req.params.id) const asset = sharedLink.assets.find(x => x.id === req.params.id)
if (asset) { if (asset) {
asset.type = req.params.type === 'video' ? AssetType.video : AssetType.image asset.type = req.params.type === 'video' ? AssetType.video : AssetType.image
render.assetBuffer(request, res, asset, getSize(req)).then() render.assetBuffer(request, res, asset, immich.validateImageSize(req.params.size)).then()
return return
} }
} }

View File

@ -16,8 +16,22 @@ class Render {
async assetBuffer (req: IncomingShareRequest, res: Response, asset: Asset, size?: ImageSize) { async assetBuffer (req: IncomingShareRequest, res: Response, asset: Asset, size?: ImageSize) {
// Prepare the request // Prepare the request
const headerList = ['content-type', 'content-length', 'last-modified', 'etag'] const headerList = ['content-type', 'content-length', 'last-modified', 'etag']
size = size === ImageSize.thumbnail ? ImageSize.thumbnail : ImageSize.original size = immich.validateImageSize(size)
const subpath = asset.type === AssetType.video ? '/video/playback' : '/' + size let subpath, sizeQueryParam
if (asset.type === AssetType.video) {
subpath = '/video/playback'
} else if (asset.type === AssetType.image) {
// For images, there are three combinations of path + query string, depending on image size
if (size === ImageSize.original && getConfigOption('ipp.downloadOriginalPhoto', false)) {
subpath = '/original'
} else if (size === ImageSize.preview || size === ImageSize.original) {
// IPP is configured in config.json to send the preview size instead of the original size
subpath = '/thumbnail'
sizeQueryParam = 'preview'
} else {
subpath = '/' + size
}
}
const headers = { range: '' } const headers = { range: '' }
// For videos, request them in 2.5MB chunks rather than the entire video // For videos, request them in 2.5MB chunks rather than the entire video
@ -33,6 +47,7 @@ class Render {
// Request data from Immich // Request data from Immich
const url = immich.buildUrl(immich.apiUrl() + '/assets/' + encodeURIComponent(asset.id) + subpath, { const url = immich.buildUrl(immich.apiUrl() + '/assets/' + encodeURIComponent(asset.id) + subpath, {
key: asset.key, key: asset.key,
size: sizeQueryParam,
password: asset.password password: asset.password
}) })
const data = await fetch(url, { headers }) const data = await fetch(url, { headers })
@ -66,7 +81,7 @@ class Render {
async gallery (res: Response, share: SharedLink, openItem?: number) { async gallery (res: Response, share: SharedLink, openItem?: number) {
const items = [] const items = []
for (const asset of share.assets) { for (const asset of share.assets) {
let video let video, downloadUrl
if (asset.type === AssetType.video) { if (asset.type === AssetType.video) {
// Populate the data-video property // Populate the data-video property
video = JSON.stringify({ video = JSON.stringify({
@ -81,9 +96,13 @@ class Render {
controls: true controls: true
} }
}) })
} else if (asset.type === AssetType.image && getConfigOption('ipp.downloadOriginalPhoto', false)) {
// Add a download link for the original-size image, if configured in config.json
downloadUrl = immich.photoUrl(share.key, asset.id, ImageSize.original, asset.password)
} }
items.push({ items.push({
originalUrl: immich.photoUrl(share.key, asset.id, undefined, asset.password), previewUrl: immich.photoUrl(share.key, asset.id, ImageSize.preview, asset.password),
downloadUrl,
thumbnailUrl: immich.photoUrl(share.key, asset.id, ImageSize.thumbnail, asset.password), thumbnailUrl: immich.photoUrl(share.key, asset.id, ImageSize.thumbnail, asset.password),
video video
}) })

View File

@ -32,6 +32,7 @@ export interface SharedLinkResult {
export enum ImageSize { export enum ImageSize {
thumbnail = 'thumbnail', thumbnail = 'thumbnail',
preview = 'preview',
original = 'original' original = 'original'
} }

View File

@ -15,7 +15,8 @@
<div class="play-icon"></div> <div class="play-icon"></div>
</a> </a>
<% } else { %> <% } else { %>
<a href="<%- item.originalUrl %>"> <a href="<%- item.previewUrl %>"<% if (item.downloadUrl) { %>
data-download-url="<%- item.downloadUrl %>"<% } %>>
<img alt="" src="<%- item.thumbnailUrl %>"/> <img alt="" src="<%- item.thumbnailUrl %>"/>
</a> </a>
<% } <% }