Initial commit

This commit is contained in:
Alan Grainger
2025-08-06 16:15:30 +02:00
commit 39f47bf3c6
7 changed files with 4359 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.idea/
.env
node_modules/
/data/
+4187
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "immich-person-to-album",
"version": "0.0.1",
"scripts": {
"dev": "ts-node src/index.ts",
"build": "npx tsc",
"start": "node dist/index.js"
},
"author": "Alan Grainger",
"license": "AGPL-3.0",
"description": "Automatically add people to an album, so you can then share the album with another user.",
"repository": {
"type": "git",
"url": "https://github.com/alangrainger/immich-person-to-album.git"
},
"main": "dist/index.js",
"bin": "dist/index.js",
"dependencies": {
"tslib": "^2.8.1",
"dotenv": "^16.4.5",
"@immich/sdk": "^1.137.3"
},
"devDependencies": {
"@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",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
}
}
+64
View File
@@ -0,0 +1,64 @@
import { init, addAssetsToAlbum, searchAssets } from '@immich/sdk'
import store from './store'
import { personConfig } from './types'
require('dotenv').config()
// Process the .env people configurations into personId/albumId pairs
const peopleConfigs: personConfig[] = Object.keys(process.env)
.filter(key => key.startsWith('CONFIG_'))
.map(key => {
const vals = (process.env[key] || '').split(':')
if (vals.length === 2) return { personId: vals[0], albumId: vals[1] }
}).filter(x => !!x)
init({ baseUrl: process.env.IMMICH_SERVER + '/api', apiKey: process.env.API_KEY || '' })
async function processPerson (param: personConfig) {
let nextPage: string | null = '1'
let mostRecent
while (nextPage !== null) {
console.log(`Processing page ${nextPage} of person ${param.personId}`)
const res = await searchAssets({
metadataSearchDto: {
// I'm using `updated` here because this is documented to be the time when
// the asset was updated in Immich, and nothing to do with the EXIF data.
// https://immich.app/docs/api/get-asset-info
// This may also be the case for `created`, but it doesn't specify
// that in the docs.
updatedAfter: store.get(store.personKey(param)),
page: parseInt(nextPage, 10), // why is `nextPage` a string and `page` a number? ¯\_(ツ)_/¯
personIds: [param.personId]
}
})
// Track the most recent photo timestamp so we can update the store
if (!mostRecent) mostRecent = res.assets.items[0].updatedAt
await addAssetsToAlbum({
id: param.albumId,
bulkIdsDto: {
ids: res.assets.items.map(x => x.id)
}
})
nextPage = res.assets.nextPage
}
// Store the most recent asset update value
await store.set(store.personKey(param), mostRecent)
}
async function main () {
for (const config of peopleConfigs) {
try {
await processPerson({
personId: config.personId,
albumId: config.albumId
})
} catch (e) {
console.log(e)
}
}
}
main().then()
+41
View File
@@ -0,0 +1,41 @@
import * as fs from 'fs'
import * as path from 'path'
import { personConfig } from './types'
export class JSONKeyValueStore<T = any> {
filePath: string
cache: Map<string, T>
constructor () {
this.filePath = path.resolve('./data/store.json')
this.cache = new Map<string, T>()
try {
const data = fs.readFileSync(this.filePath, 'utf8')
const parsed: Record<string, T> = JSON.parse(data)
this.cache = new Map(Object.entries(parsed))
} catch (error: any) {
if (error.code !== 'ENOENT') throw error
// File doesn't exist, start with empty store
}
}
private async persist (): Promise<void> {
const obj = Object.fromEntries(this.cache)
await fs.promises.writeFile(this.filePath, JSON.stringify(obj, null, 2))
}
get (key: string): T | undefined {
return this.cache.get(key)
}
async set (key: string, value: T): Promise<void> {
this.cache.set(key, value)
await this.persist()
}
personKey (param: personConfig) {
return param.personId + ':' + param.albumId
}
}
export default new JSONKeyValueStore()
+4
View File
@@ -0,0 +1,4 @@
export interface personConfig {
personId: string;
albumId: string;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"baseUrl": "src",
"outDir": "dist",
"inlineSourceMap": true,
"inlineSources": true,
"module": "CommonJS",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"esModuleInterop": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
],
"allowSyntheticDefaultImports": true
},
"include": [
"**/*.ts"
]
}