🚀 Initial commit

This commit is contained in:
Nathan Lamy 2025-08-29 22:14:39 +02:00
commit 371989c0cb
15 changed files with 1612 additions and 0 deletions

32
.dockerignore Normal file
View file

@ -0,0 +1,32 @@
# Node modules
node_modules
npm-debug.log
yarn-error.log
# Local environment files
.env
.env.local
.env.*.local
# TypeScript build output
dist
# Logs
logs
*.log
# OS files
.DS_Store
Thumbs.db
# Docker files
Dockerfile*
docker-compose*.yml
# Git
.git
.gitignore
# IDE/editor files
.vscode
.idea

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
.env

32
Dockerfile Normal file
View file

@ -0,0 +1,32 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN pnpm install
COPY . .
RUN pnpm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
COPY pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN pnpm install --prod
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "."]

14
docker-compose.yml Normal file
View file

@ -0,0 +1,14 @@
services:
immich-manager:
build: .
container_name: immich-manager
env_file:
- .env
environment:
- CLIENT_ID
- IMMICH_URL
- FRAME_URL
- IMMICH_TOKEN
ports:
- "3000:3000"
restart: unless-stopped

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "immich-assets-manager",
"version": "0.1.0",
"description": "",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "pnpm --package=typescript dlx tsc",
"start": "node ."
},
"keywords": [],
"author": "nathan-lamy",
"packageManager": "pnpm@10.13.1",
"dependencies": {
"@fastify/static": "^8.2.0",
"fastify": "^5.5.0",
"fastify-sse-v2": "^4.2.1",
"jose": "^6.1.0",
"undici": "^7.15.0"
},
"devDependencies": {
"dotenv": "^17.2.1",
"@types/node": "^24.3.0",
"typescript": "^5.9.2"
}
}

872
pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,872 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@fastify/static':
specifier: ^8.2.0
version: 8.2.0
fastify:
specifier: ^5.5.0
version: 5.5.0
fastify-sse-v2:
specifier: ^4.2.1
version: 4.2.1(fastify@5.5.0)
jose:
specifier: ^6.1.0
version: 6.1.0
undici:
specifier: ^7.15.0
version: 7.15.0
devDependencies:
'@types/node':
specifier: ^24.3.0
version: 24.3.0
dotenv:
specifier: ^17.2.1
version: 17.2.1
typescript:
specifier: ^5.9.2
version: 5.9.2
packages:
'@fastify/accept-negotiator@2.0.1':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
'@fastify/ajv-compiler@4.0.2':
resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==}
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
'@fastify/fast-json-stringify-compiler@5.0.3':
resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
'@fastify/forwarded@3.0.0':
resolution: {integrity: sha512-kJExsp4JCms7ipzg7SJ3y8DwmePaELHxKYtg+tZow+k0znUTf3cb+npgyqm8+ATZOdmfgfydIebPDWM172wfyA==}
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/proxy-addr@5.0.0':
resolution: {integrity: sha512-37qVVA1qZ5sgH7KpHkkC4z9SK6StIsIcOmpjvMPXNb3vx2GQxhZocogVYbr2PbbeLCQxYIPDok307xEvRZOzGA==}
'@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/static@8.2.0':
resolution: {integrity: sha512-PejC/DtT7p1yo3p+W7LiUtLMsV8fEvxAK15sozHy9t8kwo5r0uLYmhV/inURmGz1SkHZFz/8CNtHLPyhKcx4SQ==}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.0':
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@types/node@24.3.0':
resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.0:
resolution: {integrity: sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.1:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
engines: {node: '>=12'}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
avvio@9.1.0:
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dotenv@17.2.1:
resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
engines: {node: '>=12'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
fast-decode-uri-component@1.0.1:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-json-stringify@6.0.1:
resolution: {integrity: sha512-s7SJE83QKBZwg54dIbD5rCtzOBVD43V1ReWXXYqBgwCwHLYAAT0RQc/FmrQglXqWPpz6omtryJQOau5jI4Nrvg==}
fast-querystring@1.1.2:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
fast-redact@3.5.0:
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
engines: {node: '>=6'}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
fastify-plugin@5.0.1:
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
fastify-sse-v2@4.2.1:
resolution: {integrity: sha512-VDUgAUpu+v+WsbAjcdiG7yB9zHhL64gHSN3mneiXB12nsC2QlCw+e0W97BfLApbSYcEgEz4J+1dqO2YmVqRbRw==}
peerDependencies:
fastify: '>=4'
fastify@5.5.0:
resolution: {integrity: sha512-ZWSWlzj3K/DcULCnCjEiC2zn2FBPdlZsSA/pnPa/dbUfLvxkD/Nqmb0XXMXLrWkeM4uQPUvjdJpwtXmTfriXqw==}
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
find-my-way@9.3.0:
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
engines: {node: '>=20'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
get-iterator@1.0.2:
resolution: {integrity: sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg==}
glob@11.0.3:
resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==}
engines: {node: 20 || >=22}
hasBin: true
http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ipaddr.js@2.2.0:
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
engines: {node: '>= 10'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
it-pushable@1.4.2:
resolution: {integrity: sha512-vVPu0CGRsTI8eCfhMknA7KIBqqGFolbRx+1mbQ6XuZ7YCz995Qj7L4XUviwClFunisDq96FdxzF5FnAbw15afg==}
it-to-stream@1.0.0:
resolution: {integrity: sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==}
jackspeak@4.1.1:
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
engines: {node: 20 || >=22}
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
json-schema-ref-resolver@2.0.1:
resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
engines: {node: 20 || >=22}
mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
minimatch@10.0.3:
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
engines: {node: 20 || >=22}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
p-defer@3.0.0:
resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==}
engines: {node: '>=8'}
p-fifo@1.0.0:
resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-scurry@2.0.0:
resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==}
engines: {node: 20 || >=22}
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
pino@9.9.0:
resolution: {integrity: sha512-zxsRIQG9HzG+jEljmvmZupOMDUQ0Jpj0yAgE28jQvvrdYTlEaiGwelJpdndMl/MBuRr70heIj83QyqJUWaU8mQ==}
hasBin: true
process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
ret@0.5.0:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-regex2@5.0.0:
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
secure-json-parse@4.0.0:
resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==}
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sonic-boom@4.2.0:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.0:
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
engines: {node: '>=12'}
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
toad-cache@3.7.0:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.10.0:
resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
undici@7.15.0:
resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==}
engines: {node: '>=20.18.1'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
snapshots:
'@fastify/accept-negotiator@2.0.1': {}
'@fastify/ajv-compiler@4.0.2':
dependencies:
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
fast-uri: 3.1.0
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3':
dependencies:
fast-json-stringify: 6.0.1
'@fastify/forwarded@3.0.0': {}
'@fastify/merge-json-schemas@0.2.1':
dependencies:
dequal: 2.0.3
'@fastify/proxy-addr@5.0.0':
dependencies:
'@fastify/forwarded': 3.0.0
ipaddr.js: 2.2.0
'@fastify/send@4.1.0':
dependencies:
'@lukeed/ms': 2.0.2
escape-html: 1.0.3
fast-decode-uri-component: 1.0.1
http-errors: 2.0.0
mime: 3.0.0
'@fastify/static@8.2.0':
dependencies:
'@fastify/accept-negotiator': 2.0.1
'@fastify/send': 4.1.0
content-disposition: 0.5.4
fastify-plugin: 5.0.1
fastq: 1.19.1
glob: 11.0.3
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@lukeed/ms@2.0.2': {}
'@types/node@24.3.0':
dependencies:
undici-types: 7.10.0
abstract-logging@2.0.1: {}
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-regex@6.2.0: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.1: {}
atomic-sleep@1.0.0: {}
avvio@9.1.0:
dependencies:
'@fastify/error': 4.2.0
fastq: 1.19.1
base64-js@1.5.1: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
cookie@1.0.2: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
depd@2.0.0: {}
dequal@2.0.3: {}
dotenv@17.2.1: {}
eastasianwidth@0.2.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
escape-html@1.0.3: {}
fast-decode-uri-component@1.0.1: {}
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-json-stringify@6.0.1:
dependencies:
'@fastify/merge-json-schemas': 0.2.1
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
fast-uri: 3.1.0
json-schema-ref-resolver: 2.0.1
rfdc: 1.4.1
fast-querystring@1.1.2:
dependencies:
fast-decode-uri-component: 1.0.1
fast-redact@3.5.0: {}
fast-uri@3.1.0: {}
fastify-plugin@4.5.1: {}
fastify-plugin@5.0.1: {}
fastify-sse-v2@4.2.1(fastify@5.5.0):
dependencies:
fastify: 5.5.0
fastify-plugin: 4.5.1
it-pushable: 1.4.2
it-to-stream: 1.0.0
fastify@5.5.0:
dependencies:
'@fastify/ajv-compiler': 4.0.2
'@fastify/error': 4.2.0
'@fastify/fast-json-stringify-compiler': 5.0.3
'@fastify/proxy-addr': 5.0.0
abstract-logging: 2.0.1
avvio: 9.1.0
fast-json-stringify: 6.0.1
find-my-way: 9.3.0
light-my-request: 6.6.0
pino: 9.9.0
process-warning: 5.0.0
rfdc: 1.4.1
secure-json-parse: 4.0.0
semver: 7.7.2
toad-cache: 3.7.0
fastq@1.19.1:
dependencies:
reusify: 1.1.0
find-my-way@9.3.0:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
safe-regex2: 5.0.0
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
get-iterator@1.0.2: {}
glob@11.0.3:
dependencies:
foreground-child: 3.3.1
jackspeak: 4.1.1
minimatch: 10.0.3
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 2.0.0
http-errors@2.0.0:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
ieee754@1.2.1: {}
inherits@2.0.4: {}
ipaddr.js@2.2.0: {}
is-fullwidth-code-point@3.0.0: {}
isexe@2.0.0: {}
it-pushable@1.4.2:
dependencies:
fast-fifo: 1.3.2
it-to-stream@1.0.0:
dependencies:
buffer: 6.0.3
fast-fifo: 1.3.2
get-iterator: 1.0.2
p-defer: 3.0.0
p-fifo: 1.0.0
readable-stream: 3.6.2
jackspeak@4.1.1:
dependencies:
'@isaacs/cliui': 8.0.2
jose@6.1.0: {}
json-schema-ref-resolver@2.0.1:
dependencies:
dequal: 2.0.3
json-schema-traverse@1.0.0: {}
light-my-request@6.6.0:
dependencies:
cookie: 1.0.2
process-warning: 4.0.1
set-cookie-parser: 2.7.1
lru-cache@11.1.0: {}
mime@3.0.0: {}
minimatch@10.0.3:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minipass@7.1.2: {}
on-exit-leak-free@2.1.2: {}
p-defer@3.0.0: {}
p-fifo@1.0.0:
dependencies:
fast-fifo: 1.3.2
p-defer: 3.0.0
package-json-from-dist@1.0.1: {}
path-key@3.1.1: {}
path-scurry@2.0.0:
dependencies:
lru-cache: 11.1.0
minipass: 7.1.2
pino-abstract-transport@2.0.0:
dependencies:
split2: 4.2.0
pino-std-serializers@7.0.0: {}
pino@9.9.0:
dependencies:
atomic-sleep: 1.0.0
fast-redact: 3.5.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 2.0.0
pino-std-serializers: 7.0.0
process-warning: 5.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.0
thread-stream: 3.1.0
process-warning@4.0.1: {}
process-warning@5.0.0: {}
quick-format-unescaped@4.0.4: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
real-require@0.2.0: {}
require-from-string@2.0.2: {}
ret@0.5.0: {}
reusify@1.1.0: {}
rfdc@1.4.1: {}
safe-buffer@5.2.1: {}
safe-regex2@5.0.0:
dependencies:
ret: 0.5.0
safe-stable-stringify@2.5.0: {}
secure-json-parse@4.0.0: {}
semver@7.7.2: {}
set-cookie-parser@2.7.1: {}
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
signal-exit@4.1.0: {}
sonic-boom@4.2.0:
dependencies:
atomic-sleep: 1.0.0
split2@4.2.0: {}
statuses@2.0.1: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.0:
dependencies:
ansi-regex: 6.2.0
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0
toad-cache@3.7.0: {}
toidentifier@1.0.1: {}
typescript@5.9.2: {}
undici-types@7.10.0: {}
undici@7.15.0: {}
util-deprecate@1.0.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.1
string-width: 5.1.2
strip-ansi: 7.1.0

72
public/index.html Normal file
View file

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Immich Asset Manager</title>
<script
defer
src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.13.3/cdn.min.js"
></script>
<link rel="stylesheet" href="styles.css" />
</head>
<body x-data="photoManager()">
<div class="header">
<h1>📸 Immich Asset Manager</h1>
<div class="controls">
<button
class="control-btn"
:class="{ 'active': isPlaying }"
@click="togglePlayback()"
:title="isPlaying ? 'Pause slideshow' : 'Play slideshow'"
>
<span x-show="!isPlaying">▶️</span>
<span x-show="isPlaying">⏸️</span>
</button>
</div>
</div>
<div class="photo-grid">
<template x-if="photos.length === 0">
<div class="empty-state">
<h2>No photos yet</h2>
<p>Photos will appear here when displayed on your Immich Frame</p>
</div>
</template>
<template x-for="photo in getVisiblePhotos()" :key="photo.id">
<div class="photo-card">
<div class="photo-container">
<img
:src="photo.url"
:alt="'Photo from ' + photo.displayDate"
@error="handleImageError($event)"
/>
<div class="photo-overlay">
<div class="photo-actions">
<button
class="action-btn"
@click="openInImmich(photo.immichUrl)"
>
📱 Open in Immich
</button>
<button
class="action-btn delete-btn"
@click="deletePhoto(photo.id)"
>
🗑️ Delete
</button>
</div>
</div>
</div>
<div class="photo-info">
<div class="photo-date">
<span>🖼️ Displayed on</span> <span x-text="photo.date"></span>
</div>
</div>
</div>
</template>
</div>
<script src="main.js"></script>
</body>
</html>

59
public/main.js Normal file
View file

@ -0,0 +1,59 @@
function photoManager() {
return {
photos: [],
isPlaying: false,
// Default with immich frame
hiddenPhotosCount: 5,
maxPhotosToShow: 25,
getVisiblePhotos() {
return this.photos.length > this.hiddenPhotosCount
? this.photos.slice(this.hiddenPhotosCount, this.photos.length)
: this.photos;
},
init() {
const eventSource = new EventSource("/api/photos");
eventSource.onmessage = (event) => {
if (event.data.includes("Heartbeat")) return;
// Parse the incoming photo data
const newPhoto = JSON.parse(event.data);
this.photos.push(newPhoto);
this.photos.sort((a, b) => new Date(b.rawDate) - new Date(a.rawDate));
// Limit the number of photos to maxPhotosToShow + hiddenPhotosCount
const max = this.maxPhotosToShow + this.hiddenPhotosCount;
if (this.photos.length > max) {
// Remove oldest photos
this.photos.splice(max, this.photos.length - max);
}
};
},
togglePlayback() {
fetch("/api/pause", {
method: "POST",
})
.catch((err) => {
console.error("Failed to toggle playback", err);
})
.then(() => {
this.isPlaying = !this.isPlaying;
});
},
deletePhoto(photoId) {
if (confirm("Are you sure you want to delete this photo?")) {
// TODO:
}
},
openInImmich(photoUrl) {
window.open(photoUrl, "_blank");
},
handleImageError(event) {
event.target.src =
"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtc2l6ZT0iMTQiIGZpbGw9IiM5OTkiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5JbWFnZSBub3QgZm91bmQ8L3RleHQ+PC9zdmc+";
},
};
}

200
public/styles.css Normal file
View file

@ -0,0 +1,200 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
color: white;
}
.header h1 {
font-size: 2.5rem;
font-weight: 300;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.controls {
display: flex;
gap: 10px;
}
.control-btn {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 50%;
width: 60px;
height: 60px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
backdrop-filter: blur(10px);
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
.control-btn.active {
background: rgba(16, 172, 132, 0.8);
border-color: #10ac84;
box-shadow: 0 4px 15px rgba(16, 172, 132, 0.4);
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.photo-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 15px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.photo-card:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.photo-container {
position: relative;
height: 200px;
overflow: hidden;
}
.photo-container img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.photo-container:hover img {
transform: scale(1.05);
}
.photo-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
45deg,
rgba(102, 126, 234, 0.8),
rgba(118, 75, 162, 0.8)
);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.photo-container:hover .photo-overlay {
opacity: 1;
}
.photo-actions {
display: flex;
gap: 10px;
}
.action-btn {
padding: 8px 16px;
border: 2px solid white;
background: rgba(255, 255, 255, 0.2);
color: white;
border-radius: 20px;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
font-size: 12px;
cursor: pointer;
}
.action-btn:hover {
background: white;
color: #667eea;
transform: scale(1.05);
}
.delete-btn {
border-color: #ff6b6b;
background: rgba(255, 107, 107, 0.3);
}
.delete-btn:hover {
background: #ff6b6b;
color: white;
}
.photo-info {
padding: 15px;
text-align: center;
}
.photo-date {
color: #2c3e50;
font-size: 14px;
font-weight: 500;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
color: white;
padding: 60px 20px;
}
.empty-state h2 {
font-size: 1.8rem;
margin-bottom: 10px;
opacity: 0.8;
}
.empty-state p {
opacity: 0.7;
}
@media (max-width: 768px) {
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.header {
flex-direction: column;
gap: 20px;
text-align: center;
}
.header h1 {
font-size: 2rem;
}
}

13
src/constants.ts Normal file
View file

@ -0,0 +1,13 @@
export const APP_PORT = 3000;
export const CLIENT_ID = process.env.CLIENT_ID;
export const EVENT_NAME = "ImageRequestedNotification";
export const MAX_QUEUE_SIZE = 25;
export const SIGNATURE_EXPIRY = "1h";
export const IMMICH_URL = process.env.IMMICH_URL;
export const IMMICH_TOKEN = process.env.IMMICH_TOKEN;
export const FRAME_URL = process.env.FRAME_URL;
export const LOCALE = process.env.LOCALE || "en-US";

165
src/index.ts Normal file
View file

@ -0,0 +1,165 @@
import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { FastifySSEPlugin } from "fastify-sse-v2";
import { join } from "path";
import FixedQueue from "./queue.js";
import {
__dirname,
fetchPhoto,
formatDate,
generatePhotoUrl,
generateSignature,
togglePause,
verifySignature,
} from "./utils.js";
import {
APP_PORT,
CLIENT_ID,
EVENT_NAME,
MAX_QUEUE_SIZE,
} from "./constants.js";
import { Photo } from "./types.js";
import { randomUUID } from "crypto";
const app = Fastify();
const queue = new FixedQueue<Photo>(MAX_QUEUE_SIZE);
// Keep track of SSE clients
let clients: Array<{
reply: Fastify.FastifyReply;
id: string;
}> = [];
app.register(fastifyStatic, {
root: join(__dirname, "..", "public"),
prefix: "/", // Serve at root
decorateReply: true,
});
app.register(FastifySSEPlugin);
app.post("/api", async (req, res) => {
const { Name, ClientIdentifier, RequestedImageId, DateTime } =
req.body as any;
// Send empty response for other events or clients
if (Name !== EVENT_NAME || ClientIdentifier !== CLIENT_ID) {
return res.send({});
}
// Add the RequestedImageId to the queue
const newPhoto: Photo = {
id: RequestedImageId,
date: new Date(DateTime),
};
queue.push(newPhoto);
// Notify all SSE clients
const signature = await generateSignature(newPhoto.id);
const data = JSON.stringify({
...newPhoto,
url: `/photos/${newPhoto.id}?signature=${signature}`,
immichUrl: generatePhotoUrl(newPhoto.id),
date: formatDate(newPhoto.date),
rawDate: newPhoto.date.toISOString(),
});
for (const client of clients) {
try {
client.reply.sse({ data });
} catch (e) {
console.error(`Failed to send to client ${client.id}`, e);
}
}
return res.send({ success: true });
});
// Serve photos with a signed URL
app.get("/photos/:immichId", async (req, res) => {
const { immichId } = req.params as { immichId: string };
const signature = (req.query as any).signature as string;
if (!signature) {
return res.status(400).send("Missing signature");
}
// Verify the signature
const verifiedId = await verifySignature(signature);
if (verifiedId !== immichId) {
return res.status(403).send("Invalid signature");
}
// Redirect to Immich server
const photo = await fetchPhoto(immichId);
// Set caching headers for 1 hour
res.header("Cache-Control", "public, max-age=3600, immutable");
res.header("Content-Type", "image/jpeg");
res.send(photo);
});
// SSE endpoint for photo updates
app.get("/api/photos", async (req, reply) => {
// Add client to SSE clients array
const clientId = randomUUID();
clients.push({
reply,
id: clientId,
});
console.log(`Client ${clientId} connected. Total clients: ${clients.length}`);
req.raw.on("close", () => removeClient(clientId));
req.raw.on("error", () => removeClient(clientId));
reply.sse(
(async function* () {
// Send initial list of photos
const initialPhotos = await Promise.all(
queue
.items()
.sort((a, b) => b.date.getTime() - a.date.getTime())
.map(async (photo) => {
const s = await generateSignature(photo.id);
return {
...photo,
url: `/photos/${photo.id}?signature=${s}`,
immichUrl: generatePhotoUrl(photo.id),
date: formatDate(photo.date),
rawDate: photo.date.toISOString(),
};
})
);
for (const photo of initialPhotos) {
yield { data: JSON.stringify(photo) };
}
// Keep connection alive and handle cleanup
try {
// This generator will stay open until client disconnects
while (true) {
await new Promise((resolve) => setTimeout(resolve, 30000)); // 30 second heartbeat
yield { data: `Heartbeat for client ${clientId}` };
}
} finally {
removeClient(clientId);
}
})()
);
});
app.post("/api/pause", async (_, res) => {
try {
await togglePause();
res.send({ success: true });
} catch (e) {
console.error("Failed to toggle pause", e);
res.status(500).send({ success: false, error: "Failed to toggle pause" });
}
});
const removeClient = (clientId: string) => {
clients = clients.filter((c) => c.id !== clientId);
};
app.listen({ port: APP_PORT, host: "0.0.0.0" });

24
src/queue.ts Normal file
View file

@ -0,0 +1,24 @@
export default class FixedQueue<T> {
limit: number;
queue: T[];
constructor(limit: number) {
this.limit = limit; // max number of items to keep
this.queue = [];
}
push(item: T) {
if (this.queue.length >= this.limit) {
this.queue.shift(); // remove oldest
}
this.queue.push(item);
}
items() {
return this.queue;
}
size() {
return this.queue.length;
}
}

4
src/types.ts Normal file
View file

@ -0,0 +1,4 @@
export interface Photo {
date: Date;
id: string;
}

82
src/utils.ts Normal file
View file

@ -0,0 +1,82 @@
import { fileURLToPath } from "url";
import { dirname } from "path";
// recreate __filename and __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export { __dirname, __filename };
// Signed URL
import { generateKeyPair, SignJWT, jwtVerify } from "jose";
import {
FRAME_URL,
IMMICH_TOKEN,
IMMICH_URL,
LOCALE,
SIGNATURE_EXPIRY,
} from "./constants.js";
const { privateKey, publicKey } = await generateKeyPair("ES256");
export async function generateSignature(immichId: string) {
const jwt = await new SignJWT({ immichId })
.setProtectedHeader({ alg: "ES256" })
.setIssuedAt()
.setExpirationTime(SIGNATURE_EXPIRY)
.sign(privateKey);
return jwt;
}
export async function verifySignature(token: string) {
try {
const { payload } = await jwtVerify(token, publicKey);
return payload.immichId as string;
} catch (e) {
console.error("Invalid signature", e);
return null;
}
}
// Immich API
import { request } from "undici";
// Download a photo from Immich (with its id) and return its buffer.
// from /api/assets/ID/thumbnail?size=preview
export async function fetchPhoto(immichId: string) {
const url = `${IMMICH_URL}/api/assets/${immichId}/thumbnail?size=preview`;
const res = await request(url, {
headers: {
"x-api-key": IMMICH_TOKEN,
},
method: "GET",
});
if (res.statusCode !== 200) {
console.log(await res.body.text());
throw new Error(`Failed to fetch photo: ${res.statusCode}`);
}
const arrayBuffer = await res.body.arrayBuffer();
return Buffer.from(arrayBuffer);
}
export function generatePhotoUrl(immichId: string) {
return `${IMMICH_URL}/photos/${immichId}`;
}
// Format date to a more readable format
export function formatDate(date: Date) {
return date.toLocaleDateString(LOCALE, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
// Toggle pause state
export async function togglePause() {
await request(FRAME_URL + "/pause");
}

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}