feat: Immich album linking with auto-sync (#206)
- Link Immich albums to trips — photos sync automatically - Album picker shows all user's Immich albums - Linked albums displayed as chips with sync/unlink buttons - Auto-sync on link: fetches all album photos and adds to trip - Manual re-sync button for each linked album - DB migration: trip_album_links table fix: shared Immich photos visible to other trip members - Thumbnail/original proxy now uses photo owner's Immich credentials when userId query param is provided, fixing 404 for shared photos - i18n: album keys for all 12 languages
This commit is contained in:
@@ -439,6 +439,22 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {}
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trip_album_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
immich_album_id TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL DEFAULT '',
|
||||
sync_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_synced_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, immich_album_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -268,8 +268,9 @@ router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||
|
||||
// Only allow accessing own Immich credentials — prevent leaking other users' API keys
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
// Use photo owner's Immich credentials if userId is provided (for shared photos)
|
||||
const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||
|
||||
try {
|
||||
@@ -292,8 +293,9 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res:
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||
|
||||
// Only allow accessing own Immich credentials — prevent leaking other users' API keys
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
// Use photo owner's Immich credentials if userId is provided (for shared photos)
|
||||
const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found');
|
||||
|
||||
try {
|
||||
@@ -311,4 +313,110 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res:
|
||||
}
|
||||
});
|
||||
|
||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
// List user's Immich albums
|
||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/albums`, {
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' });
|
||||
const albums = (await resp.json() as any[]).map((a: any) => ({
|
||||
id: a.id,
|
||||
albumName: a.albumName,
|
||||
assetCount: a.assetCount || 0,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
albumThumbnailAssetId: a.albumThumbnailAssetId,
|
||||
}));
|
||||
res.json({ albums });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach Immich' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get album links for a trip
|
||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||
const links = db.prepare(`
|
||||
SELECT tal.*, u.username
|
||||
FROM trip_album_links tal
|
||||
JOIN users u ON tal.user_id = u.id
|
||||
WHERE tal.trip_id = ?
|
||||
ORDER BY tal.created_at ASC
|
||||
`).all(req.params.tripId);
|
||||
res.json({ links });
|
||||
});
|
||||
|
||||
// Link an album to a trip
|
||||
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { album_id, album_name } = req.body;
|
||||
if (!album_id) return res.status(400).json({ error: 'album_id required' });
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
|
||||
).run(tripId, authReq.user.id, album_id, album_name || '');
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: 'Album already linked' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove album link
|
||||
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.run(req.params.linkId, req.params.tripId, authReq.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Sync album — fetch all assets from Immich album and add missing ones to trip
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
|
||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.get(linkId, tripId, authReq.user.id) as any;
|
||||
if (!link) return res.status(404).json({ error: 'Album link not found' });
|
||||
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/albums/${link.immich_album_id}`, {
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' });
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)'
|
||||
);
|
||||
let added = 0;
|
||||
for (const asset of assets) {
|
||||
const r = insert.run(tripId, authReq.user.id, asset.id);
|
||||
if (r.changes > 0) added++;
|
||||
}
|
||||
|
||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||
|
||||
res.json({ success: true, added, total: assets.length });
|
||||
if (added > 0) {
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach Immich' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user