diff --git a/app/config.json b/app/config.json index 5245516..76aecaf 100644 --- a/app/config.json +++ b/app/config.json @@ -5,7 +5,9 @@ }, "singleImageGallery": false, "singleItemAutoOpen": true, - "downloadOriginalPhoto": true + "downloadOriginalPhoto": true, + "showGalleryTitle": false, + "allowDownloadAll": false }, "lightGallery": { "controls": true, diff --git a/app/package.json b/app/package.json index 1bab73d..43025d7 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "immich-public-proxy", - "version": "1.4.2", + "version": "1.4.3", "scripts": { "dev": "ts-node src/index.ts", "build": "npx tsc", @@ -16,20 +16,22 @@ }, "main": "dist/index.js", "dependencies": { - "express": "^4.21.1", - "dotenv": "^16.4.5", + "archiver": "^7.0.1", "dayjs": "^1.11.13", + "dotenv": "^16.4.5", "ejs": "^3.1.10", - "typescript": "^5.6.2", - "tslib": "^2.8.1" + "express": "^4.21.1", + "tslib": "^2.8.1", + "typescript": "^5.6.2" }, "devDependencies": { - "ts-node": "^10.9.2", - "@types/node": "^16.18.111", + "@types/archiver": "^6.0.3", "@types/express": "^4.17.21", + "@types/node": "^16.18.111", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", "eslint": "^8.49.0", - "eslint-config-standard": "^17.1.0" + "eslint-config-standard": "^17.1.0", + "ts-node": "^10.9.2" } } diff --git a/app/public/images/download-all.svg b/app/public/images/download-all.svg new file mode 100644 index 0000000..d5a61e1 --- /dev/null +++ b/app/public/images/download-all.svg @@ -0,0 +1 @@ + diff --git a/app/public/style.css b/app/public/style.css index 6fbc224..de873ec 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -16,12 +16,13 @@ html { margin: 4px; } -@media (max-width:800px) { +@media (max-width: 800px) { #lightgallery a { width: calc(100vw / 3 - 10px); height: calc(100vw / 3 - 10px); overflow: hidden; } + #lightgallery img { width: 100%; height: 100%; @@ -42,6 +43,7 @@ html { background-repeat: no-repeat; opacity: 0.5; } + #lightgallery a:has(.play-icon):hover .play-icon { opacity: 1; } @@ -49,3 +51,20 @@ html { #password { color: white; } + +#header { + font-family: system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif; + color: white; + width: 100%; + display: flex; +} + +#header h1 { + font-size: 18pt; + font-weight: bold; + margin: 4px; +} + +#download-all { + margin: auto 4px auto auto; +} diff --git a/app/src/immich.ts b/app/src/immich.ts index 035bc1c..15eea3a 100644 --- a/app/src/immich.ts +++ b/app/src/immich.ts @@ -94,8 +94,12 @@ class Immich { return } - // Everything is ok - output the link page - if (link.assets.length === 1) { + // Everything is ok - output the shared link data + + if (request.mode === 'download' && getConfigOption('ipp.allowDownloadAll', false)) { + // Download all assets as a zip file + await render.downloadAll(res, link) + } else if (link.assets.length === 1) { // This is an individual item (not a gallery) log('Serving link ' + request.key) const asset = link.assets[0] diff --git a/app/src/index.ts b/app/src/index.ts index a437a2a..3f53723 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -19,9 +19,10 @@ app.use(express.static('public', { setHeaders: addResponseHeaders })) /* * [ROUTE] This is the main URL that someone would visit if they are opening a shared link */ -app.get('/share/:key', async (req, res) => { +app.get('/share/:key/:mode?', async (req, res) => { await immich.handleShareRequest({ - key: req.params.key + key: req.params.key, + mode: req.params.mode }, res) }) diff --git a/app/src/render.ts b/app/src/render.ts index 78433f9..59d6e74 100644 --- a/app/src/render.ts +++ b/app/src/render.ts @@ -2,6 +2,7 @@ import immich from './immich' import { Response } from 'express-serve-static-core' import { Asset, AssetType, ImageSize, IncomingShareRequest, SharedLink } from './types' import { getConfigOption } from './functions' +import archiver from 'archiver' class Render { lgConfig @@ -111,6 +112,9 @@ class Render { items, openItem, title: this.title(share), + path: '/share/' + share.key, + showDownload: getConfigOption('ipp.allowDownloadAll', true), + showTitle: getConfigOption('ipp.showGalleryTitle', true), lgConfig: getConfigOption('lightGallery', {}) }) } @@ -121,6 +125,27 @@ class Render { title (share: SharedLink) { return share.description || share?.album?.albumName || '' } + + /** + * Download all assets as a zip file + */ + async downloadAll (res: Response, share: SharedLink) { + res.setHeader('Content-Type', 'application/zip') + const title = this.title(share).replace(/[^\w .-]/g, '') + '.zip' + res.setHeader('Content-Disposition', `attachment; filename="${title}"`) + const archive = archiver('zip', { zlib: { level: 9 } }) + archive.pipe(res) + for (const asset of share.assets) { + const url = immich.buildUrl(immich.apiUrl() + '/assets/' + encodeURIComponent(asset.id) + '/original', { + key: asset.key, + password: asset.password + }) + const data = await fetch(url) + archive.append(Buffer.from(await data.arrayBuffer()), { name: asset.originalFileName || asset.id }) + } + await archive.finalize() + res.end() + } } const render = new Render() diff --git a/app/src/types.ts b/app/src/types.ts index 8825be1..604da4c 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -6,6 +6,7 @@ export enum AssetType { export interface Asset { id: string; key: string; + originalFileName?: string; password?: string; type: AssetType; isTrashed: boolean; @@ -39,6 +40,7 @@ export enum ImageSize { export interface IncomingShareRequest { key: string; password?: string; + mode?: string; size?: ImageSize; range?: string; } diff --git a/app/views/gallery.ejs b/app/views/gallery.ejs index 28549b1..62752c9 100644 --- a/app/views/gallery.ejs +++ b/app/views/gallery.ejs @@ -7,6 +7,16 @@ +
<% items.forEach(item => { if (item.video) { %> @@ -15,8 +25,11 @@
<% } else { %> - - data-download-url="<%- item.downloadUrl %>"<% } %>> + + data-download-url="<%- item.downloadUrl %>" + <% } %> + > <% }