fix: migrate to Immich v3.0 API and DB schema

- Updated albumColumns and AlbumThumbnail components to display placeholder when no thumbnail is available.
- added album_user table (album ownership moved here, role='owner')
- drop asset.deviceId/deviceAssetId and albums.ownerId
- asset.duration varchar -> integer (milliseconds)
- removed isArchived instead use visibility
This commit is contained in:
sai80082
2026-07-02 20:13:29 +05:30
parent 303abc72cd
commit af4cfceb47
20 changed files with 110 additions and 79 deletions
+25 -15
View File
@@ -2,7 +2,7 @@ import { ColumnDef } from "@tanstack/react-table"
import { IAlbum } from "@/types/album"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { ArrowUpDown, MoreHorizontal, Calendar, Camera, Users, HardDrive } from "lucide-react"
import { ArrowUpDown, MoreHorizontal, Calendar, Camera, Users, HardDrive, ImageOff } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
@@ -48,15 +48,21 @@ export const albumColumns: ColumnDef<IAlbum>[] = [
const album = row.original
return (
<div className="w-16 h-16 rounded-md overflow-hidden">
<LazyImage
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)}
alt={album.albumName}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
{album.albumThumbnailAssetId ? (
<LazyImage
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)}
alt={album.albumName}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-100 dark:bg-gray-800 text-gray-400">
<ImageOff size={20} />
</div>
)}
</div>
)
},
@@ -164,16 +170,20 @@ export const albumColumns: ColumnDef<IAlbum>[] = [
},
cell: ({ row }) => {
const album = row.original
const numberOfDays = differenceInDays(album.lastPhotoDate, album.firstPhotoDate)
const numberOfDays = (album.firstPhotoDate && album.lastPhotoDate)
? differenceInDays(album.lastPhotoDate, album.firstPhotoDate)
: null
return (
<div className="space-y-1">
<div className="text-sm">
{album.firstPhotoDate ? formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy') : ''}
</div>
<div className="text-xs text-muted-foreground">
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString()} days`}
</div>
{numberOfDays !== null && (
<div className="text-xs text-muted-foreground">
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString()} days`}
</div>
)}
</div>
)
},
+30 -14
View File
@@ -8,7 +8,7 @@ import { IAlbum } from '@/types/album';
import { formatDate } from '@/helpers/date.helper';
import { Checkbox } from '@/components/ui/checkbox';
import { differenceInDays } from 'date-fns';
import { Calendar, Camera } from 'lucide-react';
import { Calendar, Camera, ImageOff } from 'lucide-react';
interface IAlbumThumbnailProps {
album: IAlbum;
@@ -18,6 +18,9 @@ interface IAlbumThumbnailProps {
export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) {
const numberOfDays = useMemo(() => {
if (!album.firstPhotoDate || !album.lastPhotoDate) {
return null;
}
return differenceInDays(album.lastPhotoDate, album.firstPhotoDate);
}, [album.firstPhotoDate, album.lastPhotoDate]);
@@ -29,16 +32,27 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum
return (
<div className="border rounded-lg overflow-hidden shadow-lg relative group">
<label className="block relative ">
<LazyImage
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)}
alt={album.albumName}
title={album.albumName}
style={{
width: '100%',
height: '200px',
objectFit: 'cover',
}}
/>
{album.albumThumbnailAssetId ? (
<LazyImage
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)}
alt={album.albumName}
title={album.albumName}
style={{
width: '100%',
height: '200px',
objectFit: 'cover',
}}
/>
) : (
<div
className="flex flex-col items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 text-gray-400"
style={{ width: '100%', height: '200px' }}
title={album.albumName}
>
<ImageOff size={32} />
<span className="text-xs">No photos</span>
</div>
)}
<div className="absolute bottom-0 w-full bg-gray-800/70 text-white text-center text-xs font-bold py-1 group-hover:hidden">
{album.firstPhotoDate ? formatDate(album.firstPhotoDate?.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate?.toString(), 'MMM d, yyyy') : ''}
</div>
@@ -54,9 +68,11 @@ export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThum
<Camera size={12} />
{humanizeNumber(album.assetCount)}
</div>
<p className="bg-gray-800 flex items-center gap-1 text-white text-xs py-1 px-2 rounded">
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`} <Calendar size={12} />
</p>
{numberOfDays !== null && (
<p className="bg-gray-800 flex items-center gap-1 text-white text-xs py-1 px-2 rounded">
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`} <Calendar size={12} />
</p>
)}
</div>
<div className={`p-4 ${selected ? 'bg-blue-500' : ''}`}>
<Link href={`/albums/${album.id}`}>
+14 -10
View File
@@ -7,6 +7,9 @@ export const pluralize = (number: number, word: string, pluralWord: string) => {
}
export const humanizeBytes = (bytes: number) => {
if (!bytes || isNaN(bytes)) {
return `0 bytes`
}
if (bytes < 1024) {
return `${bytes} bytes`
}
@@ -19,22 +22,23 @@ export const humanizeBytes = (bytes: number) => {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
export const humanizeDuration = (duration: string) => {
if (!duration) {
export const humanizeDuration = (duration: string | number | null | undefined) => {
if (duration === null || duration === undefined || duration === '') {
return null;
}
// Example input : 00:00:04.350
const [hours, minutes, seconds] = duration.split(':').map(Number);
const totalSeconds = Math.round(hours * 3600 + minutes * 60 + seconds);
const ms = Number(duration);
if (isNaN(ms)) {
return null;
}
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (totalSeconds < 3600) {
// Show only minutes
return `${Math.round(minutes)}m`;
return `${minutes}m`;
}
return `${Math.round(hours)}h ${Math.round(minutes)}m`;
return `${hours}h ${minutes}m`;
}
+2 -3
View File
@@ -163,16 +163,15 @@ export async function executeAction(
}
case "archive": {
await immichFetch("/assets", "PUT", { ids: assetIds, isArchived: true }, user);
await immichFetch("/assets", "PUT", { ids: assetIds, visibility: "archive" }, user);
return { action: "archive", assetsProcessed: assetIds.length };
}
case "tag": {
if (!config.tagName) throw new Error("Tag name is required");
// Tags API may vary by Immich version — try the standard approach
try {
// Create or get tag
const tag = await immichFetch("/tags", "POST", { name: config.tagName, type: "OBJECT" }, user);
const tag = await immichFetch("/tags", "POST", { name: config.tagName }, user);
// Tag assets
await immichFetch(`/tags/${tag.id}/assets`, "PUT", { ids: assetIds }, user);
return { action: "tag", assetsProcessed: assetIds.length };
-1
View File
@@ -29,7 +29,6 @@ export default async function handler(
}
const dbAssets = await db.selectDistinctOn([assets.id], {
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
+3 -1
View File
@@ -7,6 +7,7 @@ import { albums } from "@/schema/albums.schema";
import { count, eq, min, max, sql, and } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { albumUsers } from "@/schema/albumUsers.schema";
import { assetFaces, exif, person } from "@/schema";
export default async function handler(
@@ -31,12 +32,13 @@ export default async function handler(
faceCount: count(sql<string>`DISTINCT ${person.id}`), // Ensure unique personId
})
.from(albums)
.innerJoin(albumUsers, and(eq(albums.id, albumUsers.albumId), eq(albumUsers.role, "owner")))
.leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumId))
.leftJoin(assets, eq(albumsAssetsAssets.assetId, assets.id))
.leftJoin(exif, and(eq(assets.id, exif.assetId), eq(assets.visibility, "timeline")))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
.where(and(eq(albums.ownerId, currentUser.id), eq(albums.id, id)))
.where(and(eq(albumUsers.userId, currentUser.id), eq(albums.id, id)))
.groupBy(albums.id)
.limit(1);
+3 -1
View File
@@ -7,6 +7,7 @@ import { albums } from "@/schema/albums.schema";
import { count, desc, eq, and, isNotNull } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { albumUsers } from "@/schema/albumUsers.schema";
import { assetFaces, person } from "@/schema";
export default async function handler(
@@ -26,11 +27,12 @@ export default async function handler(
numberOfPhotos: count(assets.id),
})
.from(albums)
.innerJoin(albumUsers, and(eq(albums.id, albumUsers.albumId), eq(albumUsers.role, "owner")))
.leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumId))
.leftJoin(assets, eq(albumsAssetsAssets.assetId, assets.id))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
.where(and(eq(albums.ownerId, currentUser.id), eq(albums.id, id), isNotNull(person.id)))
.where(and(eq(albumUsers.userId, currentUser.id), eq(albums.id, id), isNotNull(person.id)))
.orderBy(desc(person.name))
.groupBy(person.id);
+5 -3
View File
@@ -7,6 +7,7 @@ import { albums } from "@/schema/albums.schema";
import { count, desc, eq, min, max, sql, and, sum, isNotNull } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { albumUsers } from "@/schema/albumUsers.schema";
import { assetFaces, exif, person } from "@/schema";
import { IAlbum } from "@/types/album";
@@ -63,12 +64,13 @@ export default async function handler(
size: sum(exif.fileSizeInByte),
order: albums.order,
isActivityEnabled: albums.isActivityEnabled,
ownerId: albums.ownerId,
ownerId: albumUsers.userId,
description: albums.description,
lastModifiedAssetTimestamp: max(exif.dateTimeOriginal),
faceCount: count(sql<string>`DISTINCT ${assetFaces.personId}`), // Ensure unique personId
})
.from(albums)
.innerJoin(albumUsers, and(eq(albums.id, albumUsers.albumId), eq(albumUsers.role, "owner")))
.leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumId))
.leftJoin(assets, and(
eq(albumsAssetsAssets.assetId, assets.id),
@@ -78,8 +80,8 @@ export default async function handler(
.leftJoin(exif, eq(assets.id, exif.assetId))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
.where(eq(albums.ownerId, currentUser.id))
.groupBy(albums.id)
.where(eq(albumUsers.userId, currentUser.id))
.groupBy(albums.id, albumUsers.userId)
.orderBy(desc(albums.createdAt));
const sortedAlbums = sortAlbums(dbAlbums as IAlbum[], sortBy, sortOrder);
+8 -2
View File
@@ -5,6 +5,7 @@ import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import API from "@/lib/api";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { albums } from "@/schema/albums.schema";
import { albumUsers } from "@/schema/albumUsers.schema";
import { and, eq, inArray } from "drizzle-orm";
import { NextApiRequest, NextApiResponse } from "next";
@@ -35,11 +36,16 @@ export default async function handler(
});
}
const ownedAlbumIds = db
.select({ albumId: albumUsers.albumId })
.from(albumUsers)
.where(and(eq(albumUsers.userId, currentUser.id), eq(albumUsers.role, "owner")));
const primaryAlbum = await db
.select()
.from(albums)
.where(
and(eq(albums.id, primaryAlbumId), eq(albums.ownerId, currentUser.id))
and(eq(albums.id, primaryAlbumId), inArray(albums.id, ownedAlbumIds))
)
.limit(1);
@@ -51,7 +57,7 @@ export default async function handler(
const secondaryAlbums = await db
.select()
.from(albums)
.where(and(inArray(albums.id, secondaryAlbumIds), eq(albums.ownerId, currentUser.id)));
.where(and(inArray(albums.id, secondaryAlbumIds), inArray(albums.id, ownedAlbumIds)));
if (secondaryAlbums.length === 0) {
@@ -9,7 +9,6 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
SELECT
a."id",
a."ownerId",
a."deviceId",
a."type",
a."originalPath",
a."isFavorite",
+3 -1
View File
@@ -3,6 +3,7 @@ import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { albums } from "@/schema/albums.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { albumUsers } from "@/schema/albumUsers.schema";
import { eq, inArray, and } from "drizzle-orm";
export const config = {
@@ -39,10 +40,11 @@ export default async function handler(
})
.from(albumsAssetsAssets)
.innerJoin(albums, eq(albumsAssetsAssets.albumId, albums.id))
.innerJoin(albumUsers, and(eq(albumUsers.albumId, albums.id), eq(albumUsers.role, "owner")))
.where(
and(
inArray(albumsAssetsAssets.assetId, assetIds),
eq(albums.ownerId, currentUser.id)
eq(albumUsers.userId, currentUser.id)
)
);
+1 -9
View File
@@ -39,19 +39,11 @@ export default async function handler(
}
const durationExpr = sql<number>`
(
CAST(SUBSTRING(${assets.duration}, 1, 2) AS DECIMAL) * 3600 +
CAST(SUBSTRING(${assets.duration}, 4, 2) AS DECIMAL) * 60 +
CAST(SUBSTRING(${assets.duration}, 7, 2) AS DECIMAL) +
CAST(SUBSTRING(${assets.duration}, 10, 3) AS DECIMAL) / 1000
)
`;
const durationExpr = sql<number>`(${assets.duration}::decimal / 1000)`;
const dbAssets = await db
.selectDistinctOn([assets.id], {
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
@@ -15,7 +15,6 @@ const getRowsByDates = async (startDateDate: Date, endDateDate: Date, currentUse
return db
.select({
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
@@ -46,7 +45,6 @@ const getRowsByDates = async (startDateDate: Date, endDateDate: Date, currentUse
const getRowsByAlbums = async (currentUser: IUser, albumId: string) => {
return db.select({
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
-1
View File
@@ -67,7 +67,6 @@ export default async function handler(
const dbAssets = await db
.selectDistinctOn([assets.id], {
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
+1 -2
View File
@@ -56,7 +56,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const dbAssets = await db.select({
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
@@ -92,7 +91,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return {
...asset,
localDateTime: new Date(asset.localDateTime),
duration: asset.duration ? parseInt(asset.duration) : 0,
duration: asset.duration ?? 0,
exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0,
exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0,
dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(),
+4 -2
View File
@@ -19,10 +19,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Test asset upload permission by calling a no-op endpoint
try {
const response = await fetch(`${ENV.IMMICH_URL}/api/assets/exist`, {
const response = await fetch(`${ENV.IMMICH_URL}/api/assets/bulk-upload-check`, {
method: "POST",
headers,
body: JSON.stringify({ deviceAssetIds: ["permission-check"], deviceId: "immich-power-tools-check" }),
body: JSON.stringify({
assets: [{ id: "permission-check", checksum: "0000000000000000000000000000000000000000" }],
}),
});
result.canUpload = response.ok;
} catch {
+8
View File
@@ -0,0 +1,8 @@
import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core';
export const albumUsers = pgTable('album_user', {
albumId: uuid('albumId').notNull(),
userId: uuid('userId').notNull(),
role: varchar('role').notNull().default('editor'),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
});
-1
View File
@@ -2,7 +2,6 @@ import { pgTable, uuid, varchar, timestamp, boolean, text } from 'drizzle-orm/pg
export const albums = pgTable('album', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
ownerId: uuid('ownerId').notNull(),
albumName: varchar('albumName').notNull().default('Untitled Album'),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
albumThumbnailAssetId: uuid('albumThumbnailAssetId'),
+2 -4
View File
@@ -1,16 +1,14 @@
import { pgTable, uuid, varchar, timestamp, boolean } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
export const assets = pgTable('asset', {
id: uuid('id').defaultRandom().primaryKey().notNull(),
deviceAssetId: varchar('deviceAssetId').notNull(),
ownerId: uuid('ownerId').notNull(),
deviceId: varchar('deviceId').notNull(),
type: varchar('type').notNull(),
originalPath: varchar('originalPath').notNull(),
fileCreatedAt: timestamp('fileCreatedAt', { withTimezone: true }).notNull(),
fileModifiedAt: timestamp('fileModifiedAt', { withTimezone: true }).notNull(),
isFavorite: boolean('isFavorite').default(false).notNull(),
duration: varchar('duration'),
duration: integer('duration'),
// checksum: bytea('checksum').notNull(),
visibility: varchar('visibility').default('timeline').notNull(),
livePhotoVideoId: uuid('livePhotoVideoId').references((): any => assets.id, { onDelete: 'set null', onUpdate: 'cascade' }),
+1 -6
View File
@@ -1,7 +1,6 @@
export interface IAsset {
id: string;
ownerId: string;
deviceId: string;
type: string;
originalPath: string;
isFavorite: boolean;
@@ -32,9 +31,7 @@ export interface IDuplicateAssetRecord {
export interface IDuplicateAsset {
id: string;
deviceAssetId: string;
ownerId: string;
deviceId: string;
libraryId: null;
type: string;
originalPath: string;
@@ -46,10 +43,8 @@ export interface IDuplicateAsset {
localDateTime: Date;
updatedAt: Date;
isFavorite: boolean;
isArchived: boolean;
isTrashed: boolean;
visibility: string;
duration: string;
duration: number | null;
exifInfo: IDuplicateAssetExifInfo;
livePhotoVideoId: null;
people: any[];