v1.6 Updates for Immich V3 API Changes
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frambe",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.0",
|
||||
"description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ const sharp = require('sharp');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
require('dotenv').config();
|
||||
|
||||
const VERSION = '1.5.2';
|
||||
const VERSION = '1.6.0';
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -49,7 +49,13 @@ function immichHeaders() { return {'x-api-key':API_KEY,'Content-Type':'applicati
|
||||
function log(msg) { console.log('['+new Date().toISOString()+'] '+msg); }
|
||||
function logErr(msg) { console.error('['+new Date().toISOString()+'] ERROR '+msg); }
|
||||
function filterAssets(assets) { return assets.filter(a=>{if(a.isTrashed)return false;if(!INCLUDE_VIDEOS&&a.type==='VIDEO')return false;return true;}); }
|
||||
function mapAsset(a) { return {id:a.id,type:a.type,fileCreatedAt:a.fileCreatedAt,fileModifiedAt:a.fileModifiedAt,isFavorite:a.isFavorite,exifInfo:a.exifInfo?{dateTimeOriginal:a.exifInfo.dateTimeOriginal,city:a.exifInfo.city,state:a.exifInfo.state,country:a.exifInfo.country,make:a.exifInfo.make,model:a.exifInfo.model}:null,originalMimeType:a.originalMimeType,duration:a.duration}; }
|
||||
// Immich v3: AssetResponseDto.duration is now a number in milliseconds (or null). Older v2 sent a "H:MM:SS.mmm" string. Normalise to ms number for the frontend, tolerating both.
|
||||
function normDuration(d){if(d==null)return null;if(typeof d==='number')return d;if(typeof d==='string'){const p=d.split(':');if(p.length===3){const h=parseFloat(p[0])||0,m=parseFloat(p[1])||0,s=parseFloat(p[2])||0;return Math.round((h*3600+m*60+s)*1000);}const n=parseFloat(d);return isNaN(n)?null:n;}return null;}
|
||||
function mapAsset(a) { return {id:a.id,type:a.type,fileCreatedAt:a.fileCreatedAt,fileModifiedAt:a.fileModifiedAt,isFavorite:a.isFavorite,exifInfo:a.exifInfo?{dateTimeOriginal:a.exifInfo.dateTimeOriginal,city:a.exifInfo.city,state:a.exifInfo.state,country:a.exifInfo.country,make:a.exifInfo.make,model:a.exifInfo.model}:null,originalMimeType:a.originalMimeType,duration:normDuration(a.duration)}; }
|
||||
// Immich v3: POST /search/metadata is the canonical way to page through assets (album assets, favorites, etc.).
|
||||
// AlbumResponseDto no longer includes `assets`. We page metadata search, defaulting visibility to 'timeline'
|
||||
// (v3 changed the default to "any", which would surface archived/hidden assets on frames).
|
||||
async function searchMetadata(filter, maxPages) { const out=[]; let page=1; const mp=maxPages||10; while(page<=mp){ const body=Object.assign({size:250,page,visibility:'timeline'},filter); const r=await fetch(IMMICH_URL+'/api/search/metadata',{method:'POST',headers:immichHeaders(),body:JSON.stringify(body)}); if(!r.ok)throw new Error('search/metadata '+r.status); const d=await r.json(); const items=(d.assets&&Array.isArray(d.assets.items))?d.assets.items:[]; out.push(...items); const next=d.assets&&d.assets.nextPage; if(!next||items.length===0)break; page=typeof next==='number'?next:page+1; } return out; }
|
||||
|
||||
// === GLOBAL SETTINGS (persisted to disk) ===
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
@@ -297,13 +303,18 @@ app.get('/api/settings',(_req,res)=>{res.json({ok:true,settings:globalSettings})
|
||||
app.get('/api/time',(_req,res)=>{const d=new Date();let tz='';try{tz=Intl.DateTimeFormat().resolvedOptions().timeZone||'';}catch(e){}res.json({ok:true,iso:d.toISOString(),epoch:d.getTime(),hours:d.getHours(),minutes:d.getMinutes(),minuteOfDay:d.getHours()*60+d.getMinutes(),tz:tz,offsetMinutes:-d.getTimezoneOffset()});});
|
||||
app.put('/api/settings',requireApiToken,(req,res)=>{applySettings(req.body||{});pushServerConfigToAll();broadcastAdminClients();res.json({ok:true,settings:globalSettings});});
|
||||
app.get('/api/server-info',async(_req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/server/version',{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const v=await r.json();res.json({ok:true,version:v});}catch(e){res.status(502).json({ok:false,error:e.message});}});
|
||||
app.get('/api/albums',async(_req,res)=>{try{const[rOwn,rShared]=await Promise.all([fetch(IMMICH_URL+'/api/albums',{headers:immichHeaders()}),fetch(IMMICH_URL+'/api/albums?shared=true',{headers:immichHeaders()})]);if(!rOwn.ok)throw new Error('Own: '+rOwn.status);const aOwn=await rOwn.json(),sharedRaw=rShared.ok?await rShared.json():[];const seen=new Set(),result=[];for(const x of aOwn){if(!seen.has(x.id)){seen.add(x.id);result.push({id:x.id,albumName:x.albumName,assetCount:x.assetCount,albumThumbnailAssetId:x.albumThumbnailAssetId,updatedAt:x.updatedAt,shared:false});}}for(const x of(Array.isArray(sharedRaw)?sharedRaw:[])){if(!seen.has(x.id)){seen.add(x.id);result.push({id:x.id,albumName:x.albumName,assetCount:x.assetCount,albumThumbnailAssetId:x.albumThumbnailAssetId,updatedAt:x.updatedAt,shared:true});}}log('Albums: '+result.length);res.json(result);}catch(e){logErr('Albums: '+e.message);res.status(502).json({error:e.message});}});
|
||||
app.get('/api/albums/:id',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/albums/'+req.params.id,{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const al=await r.json(),a=filterAssets(al.assets||[]).map(mapAsset);res.json({id:al.id,albumName:al.albumName,assetCount:a.length,assets:a});}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/albums',async(_req,res)=>{try{const[rOwn,rShared]=await Promise.all([fetch(IMMICH_URL+'/api/albums?isOwned=true',{headers:immichHeaders()}),fetch(IMMICH_URL+'/api/albums?isShared=true',{headers:immichHeaders()})]);if(!rOwn.ok)throw new Error('Own: '+rOwn.status);const aOwn=await rOwn.json(),sharedRaw=rShared.ok?await rShared.json():[];const seen=new Set(),result=[];for(const x of aOwn){if(!seen.has(x.id)){seen.add(x.id);result.push({id:x.id,albumName:x.albumName,assetCount:x.assetCount,albumThumbnailAssetId:x.albumThumbnailAssetId,updatedAt:x.updatedAt,shared:false});}}for(const x of(Array.isArray(sharedRaw)?sharedRaw:[])){if(!seen.has(x.id)){seen.add(x.id);result.push({id:x.id,albumName:x.albumName,assetCount:x.assetCount,albumThumbnailAssetId:x.albumThumbnailAssetId,updatedAt:x.updatedAt,shared:true});}}log('Albums: '+result.length);res.json(result);}catch(e){logErr('Albums: '+e.message);res.status(502).json({error:e.message});}});
|
||||
app.get('/api/albums/:id',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/albums/'+req.params.id,{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const al=await r.json();
|
||||
// Immich v3: album assets are no longer embedded in AlbumResponseDto. Fetch via search/metadata.
|
||||
let raw;if(Array.isArray(al.assets)&&al.assets.length){raw=al.assets;/* v2 fallback */}else{raw=await searchMetadata({albumIds:[req.params.id]});}
|
||||
const a=filterAssets(raw).map(mapAsset);res.json({id:al.id,albumName:al.albumName,assetCount:a.length,assets:a});}catch(e){logErr('Album: '+e.message);res.status(502).json({error:e.message});}});
|
||||
app.get('/api/people',async(_req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/people',{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const d=await r.json();res.json((d.people||d||[]).map(p=>({id:p.id,name:p.name,thumbnailPath:p.thumbnailPath})));}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/people/:id',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/people/'+req.params.id+'/assets',{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const raw=await r.json();res.json(filterAssets(Array.isArray(raw)?raw:[]).map(mapAsset));}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/people/:id',async(req,res)=>{try{let raw;try{raw=await searchMetadata({personIds:[req.params.id]});}catch(_e){const r=await fetch(IMMICH_URL+'/api/people/'+req.params.id+'/assets',{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const j=await r.json();raw=Array.isArray(j)?j:[];}res.json(filterAssets(raw).map(mapAsset));}catch(e){logErr('Person: '+e.message);res.status(502).json({error:e.message});}});
|
||||
app.get('/api/people/:id/thumbnail',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/people/'+req.params.id+'/thumbnail',{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);res.set('Content-Type',r.headers.get('content-type')||'image/jpeg');res.set('Cache-Control','public, max-age=86400');r.body.pipe(res);}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/assets/random',async(req,res)=>{try{const c=Math.min(parseInt(req.query.count,10)||50,250),r=await fetch(IMMICH_URL+'/api/assets/random?count='+c,{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);res.json(filterAssets(await r.json()).map(mapAsset));}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/assets/favorites',async(_req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/search/metadata',{method:'POST',headers:immichHeaders(),body:JSON.stringify({isFavorite:true,size:250,page:1})});if(!r.ok)throw new Error(''+r.status);const d=await r.json();res.json(filterAssets(d.assets&&d.assets.items?d.assets.items:[]).map(a=>{var m=mapAsset(a);m.isFavorite=true;return m;}));}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/assets/random',async(req,res)=>{try{const c=Math.min(parseInt(req.query.count,10)||50,250);
|
||||
// Immich v3: GET /assets/random removed. Use POST /search/random (returns a bare array).
|
||||
const r=await fetch(IMMICH_URL+'/api/search/random',{method:'POST',headers:immichHeaders(),body:JSON.stringify({size:c,visibility:'timeline'})});if(!r.ok)throw new Error(''+r.status);const d=await r.json();const raw=Array.isArray(d)?d:(d.assets&&Array.isArray(d.assets.items)?d.assets.items:[]);res.json(filterAssets(raw).map(mapAsset));}catch(e){logErr('Random: '+e.message);res.status(502).json({error:e.message});}});
|
||||
app.get('/api/assets/favorites',async(_req,res)=>{try{const raw=await searchMetadata({isFavorite:true},2);res.json(filterAssets(raw).map(a=>{var m=mapAsset(a);m.isFavorite=true;return m;}));}catch(e){logErr('Favorites: '+e.message);res.status(502).json({error:e.message});}});
|
||||
app.get('/api/assets/:id/thumbnail',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/assets/'+req.params.id+'/thumbnail?size='+(req.query.size||'preview'),{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);res.set('Content-Type',r.headers.get('content-type')||'image/jpeg');res.set('Cache-Control','public, max-age=86400');r.body.pipe(res);}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/assets/:id/optimized',async(req,res)=>{try{const id=req.params.id,ck=id+':'+IMAGE_MAX_WIDTH+':'+IMAGE_QUALITY,hit=cacheGet(ck);if(hit){res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');return res.send(hit);}const r=await fetch(IMMICH_URL+'/api/assets/'+id+'/thumbnail?size=preview',{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);const buf=await r.buffer(),opt=await sharp(buf).resize({width:IMAGE_MAX_WIDTH,withoutEnlargement:true}).jpeg({quality:IMAGE_QUALITY}).toBuffer();cacheSet(ck,opt);res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');res.send(opt);}catch(e){res.status(502).json({error:e.message});}});
|
||||
app.get('/api/assets/:id/video',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/assets/'+req.params.id+'/video/playback',{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);res.set('Content-Type',r.headers.get('content-type')||'video/mp4');res.set('Cache-Control','public, max-age=86400');const cl=r.headers.get('content-length');if(cl)res.set('Content-Length',cl);r.body.pipe(res);}catch(e){res.status(502).json({error:e.message});}});
|
||||
|
||||
Reference in New Issue
Block a user