Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.idea/
|
||||
.env
|
||||
node_modules/
|
||||
/data/
|
||||
Generated
+4187
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface personConfig {
|
||||
personId: string;
|
||||
albumId: string;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user