commit 3c7560e2e84d28fcb159e138afac3149dc52174c Author: Lorenzo Iovino Date: Mon Apr 21 15:42:27 2025 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2f06e0c --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Credenziali Google OAuth2 +GOOGLE_CLIENT_ID=tuo_client_id_google +GOOGLE_CLIENT_SECRET=tuo_client_secret_google +GOOGLE_REDIRECT_URI=http://localhost:3000/oauth2callback +GOOGLE_TOKEN_PATH=google_token.json + +# Configurazione Immich +IMMICH_API_KEY=tua_api_key_immich +IMMICH_SERVER_URL=http://localhost:3001/api + +# Configurazione Album +# Formato: ID1,ID2,ID3 +GOOGLE_PHOTOS_ALBUM_IDS=album_id_1,album_id_2 +# Formato: ID1:Nome Album 1,ID2:Nome Album 2 +IMMICH_ALBUM_NAMES=album_id_1:Nome Album 1 in Immich,album_id_2:Nome Album 2 in Immich + +# Configurazione Percorsi +TEMP_DIR=temp +SYNCED_PHOTOS_FILE=synced_photos.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a928aa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +build +.idea +.env +google_token.json +synced_photos.json +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d47336 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Sincronizzazione Google Photos → Immich (TypeScript) + +Questo progetto permette di sincronizzare automaticamente gli album condivisi di Google Photos con il tuo server Immich locale, scritto in TypeScript per una migliore manutenibilità e type safety. + +## Caratteristiche + +- Sincronizza foto da album specifici di Google Photos verso Immich +- Forte tipizzazione con TypeScript per evitare errori comuni +- Memorizza le foto già sincronizzate per evitare duplicati +- Crea automaticamente gli album in Immich se non esistono +- Supporta l'aggiornamento del token di autenticazione Google + +## Prerequisiti + +- Node.js (v14 o superiore) +- TypeScript installato globalmente o localmente +- Un server Immich funzionante +- Un account Google con accesso agli album condivisi +- Credenziali OAuth2 di Google Cloud Platform + +## Installazione + +1. Clona o scarica questo repository +2. Installa le dipendenze: + +```bash +npm install +``` + +## Configurazione + +### 1. Ottenere le credenziali OAuth2 di Google + +1. Vai alla [Console Google Cloud](https://console.cloud.google.com/) +2. Crea un nuovo progetto o seleziona uno esistente +3. Vai a "API e servizi" > "Credenziali" +4. Clicca su "Crea credenziali" e seleziona "ID client OAuth" +5. Configura l'applicazione come "App web" +6. Aggiungi `http://localhost:3000/oauth2callback` come URI di reindirizzamento +7. Salva il Client ID e il Client Secret + +### 2. Abilitare l'API di Google Photos + +1. Nella Console Google Cloud, vai a "API e servizi" > "Libreria" +2. Cerca "Photos Library API" e abilitala + +### 3. Ottenere l'API key di Immich + +1. Accedi alla tua interfaccia web di Immich +2. Vai alle impostazioni del profilo +3. Nella sezione "API Keys", genera una nuova chiave + +### 4. Configurare lo script + +Modifica il file `google-photos-to-immich-sync.ts` e aggiorna la sezione `CONFIG`: + +```typescript +const CONFIG: Config = { + // Configurazione Google Photos + googlePhotos: { + clientId: 'TUO_CLIENT_ID', // Inserisci il tuo Client ID + clientSecret: 'TUO_CLIENT_SECRET', // Inserisci il tuo Client Secret + redirectUri: 'http://localhost:3000/oauth2callback', + scopes: ['https://www.googleapis.com/auth/photoslibrary.readonly'], + tokenPath: path.join(__dirname, 'google_token.json'), + // IDs degli album da sincronizzare + albumIds: [ + 'ID_ALBUM_1', // Puoi trovare questi ID nell'URL quando apri un album su Google Photos + 'ID_ALBUM_2' + ] + }, + // Configurazione Immich + immich: { + apiKey: 'TUA_API_KEY_IMMICH', // Inserisci la tua API key di Immich + serverUrl: 'http://localhost:3001/api', // Modifica con l'URL del tuo server Immich + albumNames: { + 'ID_ALBUM_1': 'Nome Album 1 in Immich', + 'ID_ALBUM_2': 'Nome Album 2 in Immich' + } + }, + tempDir: path.join(__dirname, 'temp'), + syncedPhotosFile: path.join(__dirname, 'synced_photos.json') +}; +``` + +## Come trovare gli ID degli album di Google Photos + +1. Apri Google Photos nel browser +2. Vai all'album di cui desideri l'ID +3. L'URL avrà un formato simile a: `https://photos.google.com/album/ABC123XYZ` +4. L'ID dell'album è la parte dopo `/album/` (in questo esempio, `ABC123XYZ`) + +## Utilizzo + +### Compilazione del codice TypeScript + +Prima di eseguire lo script, compila il codice TypeScript: + +```bash +npm run build +``` + +### Esecuzione + +Per eseguire lo script compilato: + +```bash +npm start +``` + +Oppure, per sviluppo e test, puoi eseguirlo direttamente con ts-node: + +```bash +npm run dev +``` + +### Prima esecuzione + +La prima volta che esegui lo script, ti verrà chiesto di autorizzare l'accesso a Google Photos: + +1. Ti verrà mostrato un URL da visitare +2. Accedi con il tuo account Google e autorizza l'applicazione +3. Copia il codice fornito e incollalo nella console +4. Lo script inizierà la sincronizzazione + +### Esecuzioni successive + +Nelle esecuzioni successive, lo script utilizzerà il token salvato in `google_token.json` e sincronizzerà solo le nuove foto. + +## Automatizzazione + +Per automatizzare la sincronizzazione, puoi configurare un cron job: + +```bash +# Esempio: esegui la sincronizzazione ogni giorno alle 3:00 +0 3 * * * cd /percorso/al/progetto && npm start >> sync.log 2>&1 +``` + +## Estensione del codice + +Il codice TypeScript è strutturato con interfacce ben definite, rendendo facile l'estensione per aggiungere nuove funzionalità. Puoi facilmente: + +- Aggiungere supporto per filtri basati su metadata delle foto +- Implementare la sincronizzazione bidirectionale +- Creare una versione con interfaccia grafica + +## Risoluzione dei problemi + +- **Errore di compilazione TypeScript**: Verifica che tutte le dipendenze siano installate con `npm install` e che TypeScript sia installato +- **Errore di autenticazione**: Elimina il file `google_token.json` e riavvia lo script +- **Errore nel caricamento delle foto**: Verifica che l'API key di Immich sia corretta e che il server sia raggiungibile +- **Errore nel recupero delle foto da Google**: Verifica che gli ID degli album siano corretti + +## Contributi + +Contributi e miglioramenti sono benvenuti! Sentiti libero di inviare pull request o segnalare problemi. + +## Licenza + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..20c1c14 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,509 @@ +{ + "name": "node-base", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-base", + "version": "1.0.0", + "license": "ISC", + "bin": { + "node-base": ".bin/install.js" + }, + "devDependencies": { + "@types/node": "^20.11.5", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", + "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", + "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", + "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", + "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", + "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", + "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", + "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", + "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", + "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", + "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", + "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", + "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", + "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", + "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", + "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", + "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", + "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", + "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", + "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", + "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", + "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", + "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", + "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/node": { + "version": "20.11.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.5.tgz", + "integrity": "sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/esbuild": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", + "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.11", + "@esbuild/android-arm": "0.19.11", + "@esbuild/android-arm64": "0.19.11", + "@esbuild/android-x64": "0.19.11", + "@esbuild/darwin-arm64": "0.19.11", + "@esbuild/darwin-x64": "0.19.11", + "@esbuild/freebsd-arm64": "0.19.11", + "@esbuild/freebsd-x64": "0.19.11", + "@esbuild/linux-arm": "0.19.11", + "@esbuild/linux-arm64": "0.19.11", + "@esbuild/linux-ia32": "0.19.11", + "@esbuild/linux-loong64": "0.19.11", + "@esbuild/linux-mips64el": "0.19.11", + "@esbuild/linux-ppc64": "0.19.11", + "@esbuild/linux-riscv64": "0.19.11", + "@esbuild/linux-s390x": "0.19.11", + "@esbuild/linux-x64": "0.19.11", + "@esbuild/netbsd-x64": "0.19.11", + "@esbuild/openbsd-x64": "0.19.11", + "@esbuild/sunos-x64": "0.19.11", + "@esbuild/win32-arm64": "0.19.11", + "@esbuild/win32-ia32": "0.19.11", + "@esbuild/win32-x64": "0.19.11" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", + "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.0.tgz", + "integrity": "sha512-I+t79RYPlEYlHn9a+KzwrvEwhJg35h/1zHsLC2JXvhC2mdynMv6Zxzvhv5EMV6VF5qJlLlkSnMVvdZV3PSIGcg==", + "dev": true, + "dependencies": { + "esbuild": "~0.19.10", + "get-tsconfig": "^4.7.2" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5247ca6 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "google-photos-to-immich-sync", + "version": "1.0.0", + "description": "Sincronizza foto da Google Photos a Immich", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "ts-node src/main.ts" + }, + "keywords": [ + "google-photos", + "immich", + "sync", + "photos" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@types/open": "^6.2.1", + "axios": "^1.6.2", + "dotenv": "^16.3.1", + "form-data": "^4.0.0", + "gaxios": "^6.7.1", + "google-auth-library": "^9.15.1", + "googleapis": "^148.0.0", + "open": "^10.1.1" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..3aff4d8 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,759 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@types/open': + specifier: ^6.2.1 + version: 6.2.1 + axios: + specifier: ^1.6.2 + version: 1.8.4 + dotenv: + specifier: ^16.3.1 + version: 16.5.0 + form-data: + specifier: ^4.0.0 + version: 4.0.2 + gaxios: + specifier: ^6.7.1 + version: 6.7.1 + google-auth-library: + specifier: ^9.15.1 + version: 9.15.1 + googleapis: + specifier: ^148.0.0 + version: 148.0.0 + open: + specifier: ^10.1.1 + version: 10.1.1 + devDependencies: + '@types/node': + specifier: ^20.9.0 + version: 20.17.30 + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@types/node@20.17.30)(typescript@5.8.3) + typescript: + specifier: ^5.2.2 + version: 5.8.3 + +packages: + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/node@20.17.30': + resolution: {integrity: sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==} + + '@types/open@6.2.1': + resolution: {integrity: sha512-CzV16LToFaKwm1FfplVTF08E3pznw4fQNCQ87N+A1RU00zu/se7npvb6IC9db3/emnSThQ6R8qFKgrei2M4EYQ==} + deprecated: This is a stub types definition. open provides its own type definitions, so you do not need this installed. + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.14.1: + resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.8.4: + resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.0: + resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + default-browser-id@5.0.0: + resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} + engines: {node: '>=18'} + + default-browser@5.2.1: + resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.2: + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + googleapis-common@7.2.0: + resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} + engines: {node: '>=14.0.0'} + + googleapis@148.0.0: + resolution: {integrity: sha512-8PDG5VItm6E1TdZWDqtRrUJSlBcNwz0/MwCa6AL81y/RxPGXJRUwKqGZfCoVX1ZBbfr3I4NkDxBmeTyOAZSWqw==} + engines: {node: '>=14.0.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + open@10.1.1: + resolution: {integrity: sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==} + engines: {node: '>=18'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + run-applescript@7.0.0: + resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} + engines: {node: '>=18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + +snapshots: + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/node@20.17.30': + dependencies: + undici-types: 6.19.8 + + '@types/open@6.2.1': + dependencies: + open: 10.1.1 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.1 + + acorn@8.14.1: {} + + agent-base@7.1.3: {} + + arg@4.1.3: {} + + asynckit@0.4.0: {} + + axios@1.8.4: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + base64-js@1.5.1: {} + + bignumber.js@9.3.0: {} + + buffer-equal-constant-time@1.0.1: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.0.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + create-require@1.1.1: {} + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + default-browser-id@5.0.0: {} + + default-browser@5.2.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.0 + + define-lazy-prop@3.0.0: {} + + delayed-stream@1.0.0: {} + + diff@4.0.2: {} + + dotenv@16.5.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + extend@3.0.2: {} + + follow-redirects@1.15.9: {} + + form-data@4.0.2: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + + googleapis-common@7.2.0: + dependencies: + extend: 3.0.2 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + qs: 6.14.0 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + googleapis@148.0.0: + dependencies: + google-auth-library: 9.15.1 + googleapis-common: 7.2.0 + transitivePeerDependencies: + - encoding + - supports-color + + gopd@1.2.0: {} + + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + is-docker@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-stream@2.0.1: {} + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.0 + + jwa@2.0.0: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + object-inspect@1.13.4: {} + + open@10.1.1: + dependencies: + default-browser: 5.2.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + is-wsl: 3.1.0 + + proxy-from-env@1.1.0: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + run-applescript@7.0.0: {} + + safe-buffer@5.2.1: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + tr46@0.0.3: {} + + ts-node@10.9.2(@types/node@20.17.30)(typescript@5.8.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.17.30 + acorn: 8.14.1 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.8.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + typescript@5.8.3: {} + + undici-types@6.19.8: {} + + url-template@2.0.8: {} + + uuid@9.0.1: {} + + v8-compile-cache-lib@3.0.1: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + yn@3.1.1: {} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8e2ca5a --- /dev/null +++ b/src/main.ts @@ -0,0 +1,1062 @@ +import * as fs from "fs"; +import * as http from "http"; +import * as path from "path"; +import * as readline from "readline"; +import axios, { type AxiosInstance, type AxiosResponse } from "axios"; +import dotenv from "dotenv"; +import FormData from "form-data"; +import type { GaxiosResponse } from "gaxios"; +import { OAuth2Client } from "google-auth-library"; +import { google } from "googleapis"; + +// Carica le variabili d'ambiente dal file .env +dotenv.config(); + +// Define custom types for Google Photos API +interface MediaItem { + id?: string; + baseUrl?: string; + filename?: string; + mimeType?: string; + mediaMetadata?: any; +} + +interface Album { + id?: string; + title?: string; + productUrl?: string; + isWriteable?: boolean; + mediaItemsCount?: string; + coverPhotoBaseUrl?: string; +} + +// Define Photos Library API interface +interface PhotosLibraryClient { + albums: { + list: () => Promise>; + }; + mediaItems: { + search: (params: { requestBody: any }) => Promise< + GaxiosResponse<{ mediaItems?: MediaItem[]; nextPageToken?: string }> + >; + }; +} + +// Definizione delle interfacce +interface GooglePhotosConfig { + clientId: string; + clientSecret: string; + redirectUri: string; + scopes: string[]; + tokenPath: string; + albumIds: string[]; +} + +interface ImmichConfig { + apiKey: string; + serverUrl: string; + albumNames: { + [key: string]: string; + }; +} + +interface Config { + googlePhotos: GooglePhotosConfig; + immich: ImmichConfig; + tempDir: string; + syncedPhotosFile: string; +} + +interface TokenInfo { + access_token: string; + refresh_token: string; + scope: string; + token_type: string; + expiry_date: number; +} + +interface ImmichAlbum { + id: string; + albumName: string; +} + +interface ImmichAsset { + id: string; + [key: string]: any; +} + +interface SyncedPhotos { + [albumId: string]: string[]; +} + +// Parse album IDs e nomi album da variabili d'ambiente +function parseAlbumConfig(): { + albumIds: string[]; + albumNamesMap: { [key: string]: string }; +} { + const albumIdsEnv = process.env.GOOGLE_PHOTOS_ALBUM_IDS || ""; + const albumNamesEnv = process.env.IMMICH_ALBUM_NAMES || ""; + + const albumIds = albumIdsEnv + .split(",") + .map((id) => id.trim()) + .filter((id) => id !== ""); + const albumNamesMap: { [key: string]: string } = {}; + + // Formato atteso: "ID_ALBUM_1:Nome Album 1,ID_ALBUM_2:Nome Album 2" + const albumPairs = albumNamesEnv + .split(",") + .map((pair) => pair.trim()) + .filter((pair) => pair !== ""); + for (const pair of albumPairs) { + const [id, name] = pair.split(":").map((item) => item.trim()); + if (id && name) { + albumNamesMap[id] = name; + } + } + + return { albumIds, albumNamesMap }; +} + +// Configura le variabili d'ambiente +const { albumIds, albumNamesMap } = parseAlbumConfig(); + +// Configurazione +const CONFIG: Config = { + // Configurazione Google Photos + googlePhotos: { + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + redirectUri: + process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/oauth2callback", + scopes: [ + "https://www.googleapis.com/auth/photoslibrary.readonly", + "https://www.googleapis.com/auth/photoslibrary.sharing", // For shared albums + ], + tokenPath: path.join( + __dirname, + "..", + process.env.GOOGLE_TOKEN_PATH || "google_token.json", + ), + albumIds: albumIds, + }, + // Configurazione Immich + immich: { + apiKey: process.env.IMMICH_API_KEY || "", + serverUrl: process.env.IMMICH_SERVER_URL || "http://localhost:3001/api", + albumNames: albumNamesMap, + }, + // Cartella temporanea per il download delle foto + tempDir: path.join(__dirname, "..", process.env.TEMP_DIR || "temp"), + // File per tenere traccia delle foto già sincronizzate + syncedPhotosFile: path.join( + __dirname, + "..", + process.env.SYNCED_PHOTOS_FILE || "synced_photos.json", + ), +}; + +// Valida la configurazione +function validateConfig(): void { + const requiredEnvVars = [ + { name: "GOOGLE_CLIENT_ID", value: CONFIG.googlePhotos.clientId }, + { name: "GOOGLE_CLIENT_SECRET", value: CONFIG.googlePhotos.clientSecret }, + { name: "IMMICH_API_KEY", value: CONFIG.immich.apiKey }, + { + name: "GOOGLE_PHOTOS_ALBUM_IDS", + value: CONFIG.googlePhotos.albumIds.length > 0 ? "present" : "", + }, + { + name: "IMMICH_ALBUM_NAMES", + value: Object.keys(CONFIG.immich.albumNames).length > 0 ? "present" : "", + }, + ]; + + const missingVars = requiredEnvVars.filter((v) => !v.value); + + if (missingVars.length > 0) { + console.error( + "Errore: Mancano le seguenti variabili d'ambiente richieste:", + ); + missingVars.forEach((v) => console.error(`- ${v.name}`)); + console.error( + "\nAssicurati di aver configurato correttamente il file .env", + ); + process.exit(1); + } + + // Controlla che tutti gli album IDs abbiano un nome corrispondente + const missingNames = CONFIG.googlePhotos.albumIds.filter( + (id) => !CONFIG.immich.albumNames[id], + ); + if (missingNames.length > 0) { + console.warn( + "Attenzione: I seguenti album ID non hanno nomi corrispondenti configurati:", + ); + missingNames.forEach((id) => console.warn(`- ${id}`)); + console.warn("Questi album verranno saltati durante la sincronizzazione."); + } +} + +// Assicurati che la cartella temporanea esista +if (!fs.existsSync(CONFIG.tempDir)) { + fs.mkdirSync(CONFIG.tempDir, { recursive: true }); +} + +// Inizializza il client OAuth2 +const oAuth2Client = new OAuth2Client( + CONFIG.googlePhotos.clientId, + CONFIG.googlePhotos.clientSecret, + CONFIG.googlePhotos.redirectUri, +); + +// Test connection to Immich server +async function testImmichConnection( + serverUrl: string, + apiKey: string, +): Promise { + try { + // Test with a simple GET request to the server root + const response = await axios.get(`${serverUrl}/system-config`, { + headers: { + "x-api-key": apiKey, + }, + }); + + console.log("Successfully connected to Immich server"); + console.log("Server response:", response.status); + console.log( + "Server version:", + response.headers["x-immich-version"] || "Unknown", + ); + + // Try to get the server info + try { + const infoResponse = await axios.get(`${serverUrl}/system-config`, { + headers: { + "x-api-key": apiKey, + }, + }); + console.log("Server info:", infoResponse.data); + } catch (infoError) { + console.log("Could not get server info, but basic connection works"); + } + return true; + } catch (error: any) { + console.error("Failed to connect to Immich server"); + console.error("Error:", error.message); + + if (error.code === "ECONNREFUSED") { + console.error("\nConnection refused. Please check:"); + console.error("1. Is the Immich server running?"); + console.error("2. Is the hostname/IP correct?"); + console.error("3. Is the port correct?"); + console.error("4. Are there any firewalls blocking the connection?"); + } + + if (error.response) { + console.error("Response status:", error.response.status); + console.error("Response data:", error.response.data); + } + return false; + } +} + +// Carica le foto già sincronizzate +function loadSyncedPhotos(): SyncedPhotos { + try { + if (fs.existsSync(CONFIG.syncedPhotosFile)) { + return JSON.parse(fs.readFileSync(CONFIG.syncedPhotosFile, "utf8")); + } + } catch (error) { + console.error("Errore nel caricamento delle foto sincronizzate:", error); + } + return {}; +} + +// Salva le foto già sincronizzate +function saveSyncedPhotos(syncedPhotos: SyncedPhotos): void { + fs.writeFileSync( + CONFIG.syncedPhotosFile, + JSON.stringify(syncedPhotos, null, 2), + ); +} + +// Ottieni un token di accesso Google +async function getGoogleAccessToken(): Promise { + try { + // Check if we already have a saved token + if (fs.existsSync(CONFIG.googlePhotos.tokenPath)) { + const token: TokenInfo = JSON.parse( + fs.readFileSync(CONFIG.googlePhotos.tokenPath, "utf8"), + ); + oAuth2Client.setCredentials(token); + + // If token is expired or will expire soon (within 5 minutes) + if (token.expiry_date && token.expiry_date < Date.now() + 5 * 60 * 1000) { + console.log("Token expired or will expire soon, refreshing..."); + if (token["refresh_token"]) { + try { + // Refresh the token + const refreshedTokens: any = await oAuth2Client["refreshToken"]( + token["refresh_token"], + ); + const newToken = { + access_token: + refreshedTokens.tokens.access_token || token.access_token, + refresh_token: + refreshedTokens.tokens.refresh_token || token.refresh_token, + expiry_date: + refreshedTokens.tokens.expiry_date || token.expiry_date, + scope: refreshedTokens.tokens.scope || token.scope, + token_type: refreshedTokens.tokens.token_type || token.token_type, + }; + + // Save the refreshed token + fs.writeFileSync( + CONFIG.googlePhotos.tokenPath, + JSON.stringify(newToken, null, 2), + ); + + oAuth2Client.setCredentials(newToken); + console.log("Token refreshed successfully"); + } catch (refreshError) { + console.error("Error refreshing token:", refreshError); + console.log("Attempting to get a new token..."); + return await getNewToken(); + } + } else { + console.warn( + "No refresh token available, requesting new authorization", + ); + return await getNewToken(); + } + } + return oAuth2Client; + } else { + // If no token exists, get a new one + return await getNewToken(); + } + } catch (error) { + console.error("Error getting access token:", error); + throw error; + } +} + +async function getNewTokenWithServer(): Promise { + return new Promise((resolve, reject) => { + // Create a temporary local server to handle the OAuth callback + const server = http.createServer(async (req, res) => { + try { + // Parse the URL + const url = new URL(req.url || "", "http://localhost"); + const code = url.searchParams.get("code"); + + if (code) { + // Close the response with a success message + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "

Authentication successful!

You can close this window now.

", + ); + + // Close the server + server.close(); + + // Exchange the code for tokens + const { tokens } = await oAuth2Client.getToken(code); + oAuth2Client.setCredentials(tokens); + + // Save the token + fs.writeFileSync( + CONFIG.googlePhotos.tokenPath, + JSON.stringify(tokens, null, 2), + ); + + console.log("Token obtained and saved successfully"); + resolve(oAuth2Client); + } else { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + "

Authentication failed

No authorization code received.

", + ); + } + } catch (error) { + console.error("Error in OAuth callback:", error); + res.writeHead(500, { "Content-Type": "text/html" }); + res.end( + "

Authentication error

An error occurred during authentication.

", + ); + reject(error); + } + }); + + // Get a random available port or use a specific one + const port = 3000; // You can also use 0 to get a random available port + server.listen(port, () => { + // Generate the OAuth URL with the correct redirect URI + const redirectUri = `http://localhost:${port}`; + const authUrl: any = oAuth2Client.generateAuthUrl({ + access_type: "offline", + prompt: "consent", + scope: CONFIG.googlePhotos.scopes, + redirect_uri: redirectUri, + }); + + // Update the client's redirect URI + oAuth2Client["redirectUri"] = redirectUri; + + console.log("\n-----------------------------------------------------"); + console.log("Authorize this app by visiting this URL:", authUrl); + console.log("-----------------------------------------------------\n"); + console.log("The browser should automatically redirect back to the app."); + + // Open the URL in the default browser if possible + try { + const open = require("open"); + open(authUrl); + } catch (error) { + console.log( + "Could not automatically open browser. Please manually visit the URL.", + ); + } + }); + }); +} +// Ottieni un nuovo token dopo l'autorizzazione dell'utente +async function getNewToken(): Promise { + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + prompt: "consent", // Force to get a refresh token + scope: CONFIG.googlePhotos.scopes, + }); + + console.log("\n-----------------------------------------------------"); + console.log("Autorizza questa app visitando questo URL:", authUrl); + console.log("-----------------------------------------------------\n"); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve, reject) => { + rl.question( + "Inserisci il codice ottenuto dopo l'autorizzazione: ", + async (code) => { + rl.close(); + try { + console.log("Ottenimento del token con il codice fornito..."); + const { tokens } = await oAuth2Client.getToken(code.trim()); + + if (!tokens.refresh_token) { + console.warn( + "Warning: No refresh token received! You might need to revoke access and try again.", + ); + } + + oAuth2Client.setCredentials(tokens); + fs.writeFileSync( + CONFIG.googlePhotos.tokenPath, + JSON.stringify(tokens, null, 2), + ); + console.log("Token salvato in", CONFIG.googlePhotos.tokenPath); + resolve(oAuth2Client); + } catch (error) { + console.error( + "Errore durante il recupero del token di accesso:", + error, + ); + reject(error); + } + }, + ); + }); +} + +// Get all user's albums from Google Photos +async function listAllGoogleAlbums(authClient: OAuth2Client): Promise { + try { + let allAlbums: Album[] = []; + let nextPageToken: string | null | undefined = null; + + do { + const response = await axios.get( + "https://photoslibrary.googleapis.com/v1/albums", + { + params: { + pageSize: 50, + pageToken: nextPageToken || undefined, + }, + headers: { + Authorization: `Bearer ${(await authClient.getAccessToken()).token}`, + }, + }, + ); + + if (response.data.albums) { + allAlbums = allAlbums.concat(response.data.albums); + } + + nextPageToken = response.data.nextPageToken; + } while (nextPageToken); + + return allAlbums; + } catch (error) { + console.error("Error retrieving all albums:", error); + throw error; + } +} + +// Get shared albums from Google Photos +async function listSharedGoogleAlbums( + authClient: OAuth2Client, +): Promise { + try { + let allSharedAlbums: Album[] = []; + let nextPageToken: string | null | undefined = null; + + do { + const response = await axios.get( + "https://photoslibrary.googleapis.com/v1/sharedAlbums", + { + params: { + pageSize: 50, + pageToken: nextPageToken || undefined, + }, + headers: { + Authorization: `Bearer ${(await authClient.getAccessToken()).token}`, + }, + }, + ); + + if (response.data.sharedAlbums) { + allSharedAlbums = allSharedAlbums.concat(response.data.sharedAlbums); + } + + nextPageToken = response.data.nextPageToken; + } while (nextPageToken); + + return allSharedAlbums; + } catch (error) { + console.error("Error retrieving shared albums:", error); + throw error; + } +} + +// Display all albums (both user's and shared) +async function displayAllAlbums(authClient: OAuth2Client): Promise { + console.log("Fetching all available Google Photos albums..."); + const albums = await listAllGoogleAlbums(authClient); + + console.log("\n----- Your Google Photos Albums -----"); + for (const album of albums) { + console.log(`Title: ${album.title}`); + console.log(`ID: ${album.id}`); + console.log(`Media items count: ${album.mediaItemsCount || "Unknown"}`); + console.log("----------------------------------------\n"); + } + + console.log("\nFetching all shared Google Photos albums..."); + const sharedAlbums = await listSharedGoogleAlbums(authClient); + + console.log("\n----- Shared Google Photos Albums -----"); + for (const album of sharedAlbums) { + console.log(`Title: ${album.title}`); + console.log(`ID: ${album.id}`); + console.log(`Shared album URL: ${album.productUrl}`); + console.log(`Media items count: ${album.mediaItemsCount || "Unknown"}`); + console.log("----------------------------------------\n"); + } + + console.log( + "Use these album IDs in your GOOGLE_PHOTOS_ALBUM_IDS environment variable", + ); + console.log("Example format: GOOGLE_PHOTOS_ALBUM_IDS=id1,id2,id3"); + console.log( + "And map them to album names with: IMMICH_ALBUM_NAMES=id1:Name1,id2:Name2,id3:Name3", + ); +} + +// Verify if an album exists before trying to get photos +async function verifyAlbumExists( + authClient: OAuth2Client, + albumId: string, +): Promise { + try { + const token = await authClient.getAccessToken(); + const response = await axios.get( + `https://photoslibrary.googleapis.com/v1/albums/${albumId}`, + { + headers: { + Authorization: `Bearer ${token.token}`, + "Content-Type": "application/json", + }, + }, + ); + + return response.status === 200; + } catch (error) { + console.error(`Album verification failed for ID ${albumId}:`); + if (error.response && error.response.data && error.response.data.error) { + console.error( + "Error details:", + JSON.stringify(error.response.data.error, null, 2), + ); + } + + // Check if it might be a shared album + try { + const sharedAlbums = await listSharedGoogleAlbums(authClient); + const foundAlbum = sharedAlbums.find((album) => album.id === albumId); + if (foundAlbum) { + console.log( + `Found album ${albumId} as a shared album: "${foundAlbum.title}"`, + ); + return true; + } + } catch (sharedError: any) { + console.error("Error checking shared albums:", sharedError.message); + } + + return false; + } +} + +// Ottieni le foto da un album Google Photos +async function getPhotosFromAlbum( + authClient: OAuth2Client, + albumId: string, +): Promise { + try { + const auth = authClient; + const endpoint = "https://photoslibrary.googleapis.com"; + const version = "v1"; + + let photos: MediaItem[] = []; + let nextPageToken: string | null | undefined = null; + + // First, let's check if this is a shared album + let isSharedAlbum = false; + + try { + // Try to access it as a shared album first + const sharedAlbums = await listSharedGoogleAlbums(authClient); + isSharedAlbum = sharedAlbums.some((album) => album.id === albumId); + + if (isSharedAlbum) { + console.log( + `Album ${albumId} is a shared album. Using appropriate access method.`, + ); + } + } catch (error) { + console.warn( + `Could not determine if album ${albumId} is shared. Proceeding with standard method.`, + ); + } + + do { + try { + const response: AxiosResponse<{ + mediaItems?: MediaItem[]; + nextPageToken?: string; + }> = await axios.post( + `${endpoint}/${version}/mediaItems:search`, + { + albumId: albumId, + pageSize: 100, + pageToken: nextPageToken || undefined, + }, + { + headers: { + Authorization: `Bearer ${(await auth.getAccessToken()).token}`, + "Content-Type": "application/json", + }, + }, + ); + + if (response.data.mediaItems) { + photos = photos.concat(response.data.mediaItems); + } + + nextPageToken = response.data.nextPageToken; + } catch (error) { + console.error(`Error retrieving photos from album ${albumId}:`); + if ( + error.response && + error.response.data && + error.response.data.error + ) { + console.error( + "Error details:", + JSON.stringify(error.response.data.error, null, 2), + ); + } + + // Special handling for shared albums + if (isSharedAlbum) { + console.error( + "This is a shared album. Make sure you have the correct access permissions.", + ); + } + + throw error; + } + } while (nextPageToken); + + return photos; + } catch (error) { + console.error( + `Errore nel recupero delle foto dall'album ${albumId}:`, + error, + ); + throw error; + } +} + +// Scarica una foto da Google Photos +async function downloadPhoto(url: string, filePath: string): Promise { + try { + const response = await axios({ + url, + method: "GET", + responseType: "stream", + }); + + const writer = fs.createWriteStream(filePath); + + return new Promise((resolve, reject) => { + response.data.pipe(writer); + writer.on("finish", resolve); + writer.on("error", reject); + }); + } catch (error) { + console.error(`Errore nel download della foto da ${url}:`, error); + throw error; + } +} + +// Interagisci con l'API di Immich +class ImmichAPI { + private apiKey: string; + private serverUrl: string; + private axios: AxiosInstance; + + constructor(apiKey: string, serverUrl: string) { + this.apiKey = apiKey; + this.serverUrl = serverUrl; + this.axios = axios.create({ + baseURL: serverUrl, + headers: { + "x-api-key": apiKey, + }, + }); + } + + async getAlbums(): Promise { + try { + try { + // Try newer endpoint first + const response = await this.axios.get("/albums"); + return response.data; + } catch (error: any) { + if (error.response && error.response.status === 404) { + console.log( + "Newer album endpoint not found, trying legacy endpoint...", + ); + // Try legacy endpoint + const response = await this.axios.get("/album"); + return response.data; + } + throw error; + } + } catch (error) { + console.error("Errore nel recupero degli album da Immich:", error); + // Return empty array to prevent script from crashing + return []; + } + } + + async createAlbum(name: string): Promise { + try { + try { + // Try newer endpoint first + const response = await this.axios.post("/albums", { + albumName: name, + }); + return response.data; + } catch (error: any) { + if (error.response && error.response.status === 404) { + console.log( + "Newer album creation endpoint not found, trying legacy endpoint...", + ); + // Try legacy endpoint + const response = await this.axios.post("/album", { + albumName: name, + }); + return response.data; + } + throw error; + } + } catch (error) { + console.error( + `Errore nella creazione dell'album "${name}" in Immich:`, + error, + ); + throw error; + } + } + + async uploadPhoto( + filePath: string, + albumId: string | null = null, + ): Promise { + try { + // Step 1: Upload the asset first + const form = new FormData(); + const filename = path.basename(filePath); + const deviceAssetId = `web-${filename}-${Date.now()}`; + const fileCreatedAt = new Date().toISOString(); + const fileModifiedAt = fileCreatedAt; + + // Add the required form fields + form.append("deviceAssetId", deviceAssetId); + form.append("deviceId", "WEB"); + form.append("fileCreatedAt", fileCreatedAt); + form.append("fileModifiedAt", fileModifiedAt); + form.append("isFavorite", "false"); + form.append("duration", "0:00:00.000000"); + form.append("assetData", fs.createReadStream(filePath)); + + // Upload the asset + const response = await this.axios.post("/assets", form, { + headers: { + ...form.getHeaders(), + }, + }); + + const assetId = response.data.id; + + // Step 2: If an album ID is provided, add the asset to the album + if (albumId) { + await this.addAssetToAlbum(assetId, albumId); + } + + return response.data; + } catch (error: any) { + console.error( + `Errore nel caricamento della foto ${filePath} su Immich:`, + error, + ); + + // Log more detailed error information + if (error.response) { + console.error("Response status:", error.response.status); + console.error("Response data:", error.response.data); + console.error( + "Headers:", + JSON.stringify(error.response.headers, null, 2), + ); + } + + throw error; + } + } + + async addAssetToAlbum(assetId: string, albumId: string): Promise { + try { + // Updated to match the API call in your curl example + await this.axios.put(`/albums/${albumId}/assets`, { + ids: [assetId], // Note: using 'ids' instead of 'assetIds' as per your curl example + }); + } catch (error: any) { + if (error.response && error.response.status === 404) { + console.log( + "Newer album assets endpoint not found, trying legacy endpoint...", + ); + // Try legacy endpoint with the old parameter name + await this.axios.put(`/album/${albumId}/assets`, { + assetIds: [assetId], + }); + } else { + console.error( + `Errore nell'aggiunta dell'asset ${assetId} all'album ${albumId}:`, + error, + ); + throw error; + } + } + } +} + +// Funzione principale +async function syncGooglePhotosToImmich(): Promise { + console.log("Avvio sincronizzazione da Google Photos a Immich..."); + + // Check if the --list-albums argument is provided + if (process.argv.includes("--list-albums")) { + const authClient = await getGoogleAccessToken(); + await displayAllAlbums(authClient); + return; + } + + // Valida la configurazione prima di iniziare + validateConfig(); + + // Test Immich connection + console.log( + `Testing connection to Immich server at ${CONFIG.immich.serverUrl}...`, + ); + const connectionSuccessful = await testImmichConnection( + CONFIG.immich.serverUrl, + CONFIG.immich.apiKey, + ); + + if (!connectionSuccessful) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const continueSync = await new Promise((resolve) => { + rl.question( + "Connection to Immich server failed. Do you want to continue anyway? (y/n): ", + (answer) => { + rl.close(); + resolve( + answer.toLowerCase() === "y" || answer.toLowerCase() === "yes", + ); + }, + ); + }); + + if (!continueSync) { + console.log("Synchronization aborted by user"); + return; + } + } + + try { + // Inizializza il client di autenticazione Google + const authClient = await getGoogleAccessToken(); + + // Inizializza l'API di Immich + const immichApi = new ImmichAPI( + CONFIG.immich.apiKey, + CONFIG.immich.serverUrl, + ); + + // Carica le foto già sincronizzate + const syncedPhotos = loadSyncedPhotos(); + + // Ottieni gli album di Immich + const immichAlbums = await immichApi.getAlbums(); + const immichAlbumsMap: { [key: string]: string } = {}; + + // Mappa gli album di Immich per nome + for (const album of immichAlbums) { + immichAlbumsMap[album.albumName] = album.id; + } + + // Output album IDs from config for debugging + console.log(CONFIG.googlePhotos.albumIds); + + // Processa ogni album configurato + for (const albumId of CONFIG.googlePhotos.albumIds) { + const immichAlbumName = CONFIG.immich.albumNames[albumId]; + + if (!immichAlbumName) { + console.warn( + `Nessun nome di album Immich configurato per l'album Google Photos ${albumId}, saltando...`, + ); + continue; + } + + console.log( + `Sincronizzazione dell'album "${immichAlbumName}" (ID Google Photos: ${albumId})...`, + ); + + // Verify the album exists first + const albumExists = await verifyAlbumExists(authClient, albumId); + if (!albumExists) { + console.error( + `Album con ID ${albumId} non trovato o non accessibile. Saltando...`, + ); + continue; + } + + // Crea l'album in Immich se non esiste + let immichAlbumId = immichAlbumsMap[immichAlbumName]; + if (!immichAlbumId) { + console.log( + `Album "${immichAlbumName}" non trovato in Immich, creazione in corso...`, + ); + const newAlbum = await immichApi.createAlbum(immichAlbumName); + immichAlbumId = newAlbum.id; + immichAlbumsMap[immichAlbumName] = immichAlbumId; + } + + // Inizializza l'array delle foto sincronizzate per questo album se non esiste + if (!syncedPhotos[albumId]) { + syncedPhotos[albumId] = []; + } + + // Ottieni le foto dall'album di Google Photos + const photos = await getPhotosFromAlbum(authClient, albumId); + console.log(`Trovate ${photos.length} foto nell'album di Google Photos.`); + + // Filtra le foto non ancora sincronizzate + const photosToSync = photos.filter( + (photo) => !syncedPhotos[albumId].includes(photo.id || ""), + ); + console.log(`${photosToSync.length} nuove foto da sincronizzare.`); + + // Sincronizza ogni foto + for (const [index, photo] of photosToSync.entries()) { + if (!photo.id || !photo.baseUrl || !photo.filename) { + console.warn(`Foto con dati mancanti, saltando...`, photo); + continue; + } + + console.log( + `Sincronizzazione della foto ${index + 1}/${photosToSync.length}: ${photo.filename}`, + ); + + // Scarica la foto + const downloadUrl = `${photo.baseUrl}=d`; // =d significa scaricare l'originale + const tempFilePath = path.join(CONFIG.tempDir, photo.filename); + + await downloadPhoto(downloadUrl, tempFilePath); + console.log(`Foto scaricata in ${tempFilePath}`); + + // Carica la foto su Immich + await immichApi.uploadPhoto(tempFilePath, immichAlbumId); + console.log(`Foto caricata su Immich nell'album "${immichAlbumName}"`); + + // Elimina il file temporaneo + fs.unlinkSync(tempFilePath); + + // Aggiungi l'ID della foto all'elenco delle foto sincronizzate + syncedPhotos[albumId].push(photo.id); + + // Salva lo stato di sincronizzazione dopo ogni foto + saveSyncedPhotos(syncedPhotos); + } + + console.log( + `Sincronizzazione dell'album "${immichAlbumName}" completata.`, + ); + } + + console.log( + "Sincronizzazione da Google Photos a Immich completata con successo!", + ); + } catch (error) { + console.error("Errore durante la sincronizzazione:", error); + } +} + +// Esegui la sincronizzazione +syncGooglePhotosToImmich(); + +// Esporta le funzioni per l'uso in altri script +export { syncGooglePhotosToImmich, CONFIG, ImmichAPI }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..93f92d6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}