diff --git a/public/images/play.png b/public/images/play.png new file mode 100644 index 0000000..c4d2c44 Binary files /dev/null and b/public/images/play.png differ diff --git a/public/lg-video.min.js b/public/lg-video.min.js new file mode 100644 index 0000000..982278a --- /dev/null +++ b/public/lg-video.min.js @@ -0,0 +1,8 @@ +/** + * lightgallery | 2.8.0-beta.3 | May 3rd 2024 + * http://www.lightgalleryjs.com/ + * Copyright (c) 2020 Sachin Neravath; + * @license GPLv3 + */ + +!function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o():"function"==typeof define&&define.amd?define(o):(e="undefined"!=typeof globalThis?globalThis:e||self).lgVideo=o()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var o,i=1,t=arguments.length;i"}else if(n.vimeo){u="lg-vimeo"+i;var h=function(e,o){if(!o||!o.vimeo)return"";var i=o.vimeo[2]||"",t=Object.assign({},{autoplay:0,muted:1},e),s=t&&0!==Object.keys(t).length?r(t):"",n=((o.vimeo[0].split("/").pop()||"").split("?")[0]||"").split("#")[0],l=o.vimeo[1]!==n;l&&(i=i.replace("/"+n,""));var d=l?"h="+n:"";return"?"+d+(s=d?"&"+s:s)+("?"==i[0]?"&"+i.slice(1):i||"")}(this.settings.vimeoPlayerParams,n);s='"}else if(n.wistia){var g="lg-wistia"+i;h=(h=r(this.settings.wistiaPlayerParams))?"?"+h:"",s='"}else if(n.html5){for(var p="",y=0;y"}if(t.tracks){var w=function(e){var o="",i=t.tracks[e];Object.keys(i||{}).forEach((function(e){o+=e+'="'+i[e]+'" '})),p+=""};for(y=0;y\n "+p+"\n Your browser does not support HTML5 video.\n "}return s},a.prototype.appendVideos=function(e,o){var i,t=this.getVideoHtml(o.src,o.addClass,o.index,o.html5Video);e.find(".lg-video-cont").append(t);var s=e.find(".lg-video-object").first();if(o.html5Video&&s.on("mousedown.lg.video",(function(e){e.stopPropagation()})),this.settings.videojs&&(null===(i=this.core.galleryItems[o.index].__slideVideoInfo)||void 0===i?void 0:i.html5))try{return videojs(s.get(),this.settings.videojsOptions)}catch(e){console.error("lightGallery:- Make sure you have included videojs")}},a.prototype.gotoNextSlideOnVideoEnd=function(e,o){var i=this,t=this.core.getSlideItem(o).find(".lg-video-object").first(),s=this.core.galleryItems[o].__slideVideoInfo||{};if(this.settings.gotoNextSlideOnVideoEnd)if(s.html5)t.on("ended",(function(){i.core.goToNextSlide()}));else if(s.vimeo)try{new Vimeo.Player(t.get()).on("ended",(function(){i.core.goToNextSlide()}))}catch(e){console.error("lightGallery:- Make sure you have included //github.com/vimeo/player.js")}else if(s.wistia)try{window._wq=window._wq||[],window._wq.push({id:t.attr("id"),onReady:function(e){e.bind("end",(function(){i.core.goToNextSlide()}))}})}catch(e){console.error("lightGallery:- Make sure you have included //fast.wistia.com/assets/external/E-v1.js")}},a.prototype.controlVideo=function(e,o){var i=this.core.getSlideItem(e).find(".lg-video-object").first(),t=this.core.galleryItems[e].__slideVideoInfo||{};if(i.get())if(t.youtube)try{i.get().contentWindow.postMessage('{"event":"command","func":"'+o+'Video","args":""}',"*")}catch(e){console.error("lightGallery:- "+e)}else if(t.vimeo)try{new Vimeo.Player(i.get())[o]()}catch(e){console.error("lightGallery:- Make sure you have included //github.com/vimeo/player.js")}else if(t.html5)if(this.settings.videojs)try{videojs(i.get())[o]()}catch(e){console.error("lightGallery:- Make sure you have included videojs")}else i.get()[o]();else if(t.wistia)try{window._wq=window._wq||[],window._wq.push({id:i.attr("id"),onReady:function(e){e[o]()}})}catch(e){console.error("lightGallery:- Make sure you have included //fast.wistia.com/assets/external/E-v1.js")}},a.prototype.loadVideoOnPosterClick=function(e,o){var i=this;if(e.hasClass("lg-video-loaded"))o&&this.playVideo(this.core.index);else if(e.hasClass("lg-has-video"))this.playVideo(this.core.index);else{e.addClass("lg-has-video");var t=void 0,s=this.core.galleryItems[this.core.index].src,n=this.core.galleryItems[this.core.index].video;n&&(t="string"==typeof n?JSON.parse(n):n);var l=this.appendVideos(e,{src:s,addClass:"",index:this.core.index,html5Video:t});this.gotoNextSlideOnVideoEnd(s,this.core.index);var r=e.find(".lg-object").first().get();e.find(".lg-video-cont").first().append(r),e.addClass("lg-video-loading"),l&&l.ready((function(){l.on("loadedmetadata",(function(){i.onVideoLoadAfterPosterClick(e,i.core.index)}))})),e.find(".lg-video-object").first().on("load.lg error.lg loadedmetadata.lg",(function(){setTimeout((function(){i.onVideoLoadAfterPosterClick(e,i.core.index)}),50)}))}},a.prototype.onVideoLoadAfterPosterClick=function(e,o){e.addClass("lg-video-loaded"),this.playVideo(o)},a.prototype.destroy=function(){this.core.LGel.off(".lg.video"),this.core.LGel.off(".video")},a}()})); diff --git a/public/style.css b/public/style.css index a8b3540..f82fc88 100644 --- a/public/style.css +++ b/public/style.css @@ -2,13 +2,32 @@ html { background: #191919; } -#lightgallery a { - text-decoration: none; - margin: 0; - padding: 0; -} - #lightgallery img { height: 250px; margin: 4px; } + +#lightgallery a { + position: relative; /* Establishes a positioning context */ + display: inline-block; /* Allows the container to wrap around the image */ + text-decoration: none; + margin: 0; + padding: 0; + cursor: pointer; +} + +.play-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50px; + height: 50px; + background-image: url('/images/play.png'); + background-size: contain; + background-repeat: no-repeat; + opacity: 0.5; +} +#lightgallery a:has(.play-icon):hover .play-icon { + opacity: 1; +} diff --git a/src/immich.ts b/src/immich.ts index 8ec0c21..e784310 100644 --- a/src/immich.ts +++ b/src/immich.ts @@ -1,14 +1,15 @@ import { Asset, AssetType, ImageSize, SharedLink } from './types' class Immich { - async request (endpoint: string, json = true) { + async request (endpoint: string) { const res = await fetch(process.env.IMMICH_URL + '/api' + endpoint, { headers: { 'x-api-key': process.env.API_KEY || '' } }) if (res.status === 200) { - if (json) { + const contentType = res.headers.get('Content-Type') || '' + if (contentType.includes('application/json')) { return res.json() } else { return res @@ -17,25 +18,38 @@ class Immich { } async getShareByKey (key: string) { - const links = await this.request('/shared-links') as SharedLink[] - return links.find(x => x.key === key) + const res = (await this.request('/shared-links') || []) as SharedLink[] + return res?.find(x => x.key === key) } async getAssetBuffer (asset: Asset, size?: ImageSize) { switch (asset.type) { case AssetType.image: size = size === ImageSize.thumbnail ? ImageSize.thumbnail : ImageSize.original - return this.request('/assets/' + asset.id + '/' + size, false) + return this.request('/assets/' + asset.id + '/' + size) case AssetType.video: - return this.request('/assets/' + asset.id + '/video/playback', false) + return this.request('/assets/' + asset.id + '/video/playback') } } - imageUrl (id: string, size?: ImageSize) { + async getContentType (asset: Asset) { + const assetBuffer = await this.getAssetBuffer(asset) + return assetBuffer.headers.get('Content-Type') + } + + photoUrl (id: string, size?: ImageSize) { return `${process.env.SERVER_URL}/photo/${id}` + (size ? `?size=${size}` : '') } + + videoUrl (id: string) { + return `${process.env.SERVER_URL}/video/${id}` + } + + isId (id: string) { + return !!id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + } } -const api = new Immich() +const immich = new Immich() -export default api +export default immich diff --git a/src/index.ts b/src/index.ts index 3c10206..ef3b677 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ import express from 'express' -import api from './immich' import immich from './immich' +import render from './render' import dayjs from 'dayjs' import { AssetType, ImageSize } from './types' -import { Request, Response } from 'express-serve-static-core' +import { Request } from 'express-serve-static-core' require('dotenv').config() @@ -11,22 +11,6 @@ const app = express() app.set('view engine', 'ejs') app.use(express.static('public')) -async function serveImage (res: Response, id: string, size?: ImageSize) { - const image = await api.getAssetBuffer({ - id, - type: AssetType.image - }, size) - if (image) { - for (const header of ['content-type', 'content-length']) { - res.set(header, image.headers[header]) - } - console.log(`${dayjs().format()} Serving image ${id}`) - res.send(Buffer.from(await image.arrayBuffer())) - } else { - res.status(404).send() - } -} - const getSize = (req: Request) => { return req?.query?.size === 'thumbnail' ? ImageSize.thumbnail : ImageSize.original } @@ -36,33 +20,42 @@ app.get('/share/:key', async (req, res) => { // Invalid characters in the incoming URL res.status(404).send() } else { - const share = await api.getShareByKey(req.params.key) + const share = await immich.getShareByKey(req.params.key) if (!share || !share.assets.length) { res.status(404).send() } else if (share.assets.length === 1) { // This is an individual item (not a gallery) - await serveImage(res, share.assets[0].id, getSize(req)) + const asset = share.assets[0] + if (asset.type === AssetType.image) { + // Output the image directly + await render.assetBuffer(res, share.assets[0], getSize(req)) + } else if (asset.type === AssetType.video) { + // Show the video as a web player + await render.gallery(res, share.assets, 1) + } } else { // Multiple images - render as a gallery - res.render('gallery', { - photos: share.assets.map(photo => { - return { - originalUrl: immich.imageUrl(photo.id), - thumbnailUrl: immich.imageUrl(photo.id, ImageSize.thumbnail) - } - }) - }) + await render.gallery(res, share.assets, 1) } } }) -app.get('/photo/:id', (req, res) => { - if (req.params.id.match(/^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$/)) { - // Check for photo - serveImage(res, req.params.id, getSize(req)).then() - } else { +// Output the buffer data for an photo or video +app.get('/:type(photo|video)/:id', (req, res) => { + if (!immich.isId(req.params.id)) { // Invalid characters in the incoming URL res.status(404).send() + return + } + const asset = { + id: req.params.id, + type: req.params.type === 'video' ? AssetType.video : AssetType.image + } + switch (req.params.type) { + case 'photo': + case 'video': + render.assetBuffer(res, asset, getSize(req)).then() + break } }) diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..dcfac2e --- /dev/null +++ b/src/render.ts @@ -0,0 +1,54 @@ +import immich from './immich' +import { Response } from 'express-serve-static-core' +import { Asset, AssetType, ImageSize } from './types' +import dayjs from 'dayjs' + +class Render { + async assetBuffer (res: Response, asset: Asset, size?: ImageSize) { + const data = await immich.getAssetBuffer(asset, size) + if (data) { + for (const header of ['content-type', 'content-length']) { + res.set(header, data.headers[header]) + } + console.log(`${dayjs().format()} Serving asset ${asset.id}`) + res.send(Buffer.from(await data.arrayBuffer())) + } else { + res.status(404).send() + } + } + + async gallery (res: Response, assets: Asset[], openItem?: number) { + const items = [] + for (const asset of assets) { + let video + if (asset.type === AssetType.video) { + // Populate the data-video property + video = JSON.stringify({ + source: [ + { + src: immich.videoUrl(asset.id), + type: await immich.getContentType(asset) + } + ], + attributes: { + preload: false, + controls: true + } + }) + } + items.push({ + originalUrl: immich.photoUrl(asset.id), + thumbnailUrl: immich.photoUrl(asset.id, ImageSize.thumbnail), + video + }) + } + res.render('gallery', { + items, + openItem + }) + } +} + +const render = new Render() + +export default render diff --git a/views/gallery.ejs b/views/gallery.ejs index 007ed21..20c5a40 100644 --- a/views/gallery.ejs +++ b/views/gallery.ejs @@ -7,21 +7,36 @@
- <% photos.forEach(photo => { %> - - + <% items.forEach(item => { + if (item.video) { %> + + +
- <% }) %> + <% } else { %> + + + + <% } + }) %>
+