diff --git a/src/components/albums/AlbumTableColumns.tsx b/src/components/albums/AlbumTableColumns.tsx index 2109247..7ed9bb6 100644 --- a/src/components/albums/AlbumTableColumns.tsx +++ b/src/components/albums/AlbumTableColumns.tsx @@ -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[] = [ const album = row.original return (
- + {album.albumThumbnailAssetId ? ( + + ) : ( +
+ +
+ )}
) }, @@ -164,16 +170,20 @@ export const albumColumns: ColumnDef[] = [ }, 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 (
{album.firstPhotoDate ? formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy') : ''} - {album.lastPhotoDate ? formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy') : ''}
-
- {numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString()} days`} -
+ {numberOfDays !== null && ( +
+ {numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString()} days`} +
+ )}
) }, diff --git a/src/components/albums/list/AlbumThumbnail.tsx b/src/components/albums/list/AlbumThumbnail.tsx index af70d22..45dbb0e 100644 --- a/src/components/albums/list/AlbumThumbnail.tsx +++ b/src/components/albums/list/AlbumThumbnail.tsx @@ -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 (
-

- {numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`} -

+ {numberOfDays !== null && ( +

+ {numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`} +

+ )}
diff --git a/src/helpers/string.helper.ts b/src/helpers/string.helper.ts index 28e77e4..db06d16 100644 --- a/src/helpers/string.helper.ts +++ b/src/helpers/string.helper.ts @@ -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`; } diff --git a/src/lib/workflow/actionExecutor.ts b/src/lib/workflow/actionExecutor.ts index 3625a93..1fd09b1 100644 --- a/src/lib/workflow/actionExecutor.ts +++ b/src/lib/workflow/actionExecutor.ts @@ -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 }; diff --git a/src/pages/api/albums/[id]/assets.ts b/src/pages/api/albums/[id]/assets.ts index 2c0e683..17327af 100644 --- a/src/pages/api/albums/[id]/assets.ts +++ b/src/pages/api/albums/[id]/assets.ts @@ -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, diff --git a/src/pages/api/albums/[id]/info.ts b/src/pages/api/albums/[id]/info.ts index e0fb466..c9dc209 100644 --- a/src/pages/api/albums/[id]/info.ts +++ b/src/pages/api/albums/[id]/info.ts @@ -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`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); diff --git a/src/pages/api/albums/[id]/people.ts b/src/pages/api/albums/[id]/people.ts index b39c3db..364af31 100644 --- a/src/pages/api/albums/[id]/people.ts +++ b/src/pages/api/albums/[id]/people.ts @@ -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); diff --git a/src/pages/api/albums/list.ts b/src/pages/api/albums/list.ts index 4eb2f17..275b375 100644 --- a/src/pages/api/albums/list.ts +++ b/src/pages/api/albums/list.ts @@ -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`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); diff --git a/src/pages/api/albums/merge.ts b/src/pages/api/albums/merge.ts index 45e56ab..979c2fe 100644 --- a/src/pages/api/albums/merge.ts +++ b/src/pages/api/albums/merge.ts @@ -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) { diff --git a/src/pages/api/albums/potential-albums-assets.ts b/src/pages/api/albums/potential-albums-assets.ts index 10832cc..89d078f 100644 --- a/src/pages/api/albums/potential-albums-assets.ts +++ b/src/pages/api/albums/potential-albums-assets.ts @@ -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", diff --git a/src/pages/api/assets/albums-by-assets.ts b/src/pages/api/assets/albums-by-assets.ts index cf29784..511c76f 100644 --- a/src/pages/api/assets/albums-by-assets.ts +++ b/src/pages/api/assets/albums-by-assets.ts @@ -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) ) ); diff --git a/src/pages/api/assets/empty-videos.ts b/src/pages/api/assets/empty-videos.ts index 4a7b62d..46d4fbd 100644 --- a/src/pages/api/assets/empty-videos.ts +++ b/src/pages/api/assets/empty-videos.ts @@ -39,19 +39,11 @@ export default async function handler( } - const durationExpr = sql` - ( - 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`(${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, diff --git a/src/pages/api/assets/missing-location-assets.ts b/src/pages/api/assets/missing-location-assets.ts index e7bd342..09105a4 100644 --- a/src/pages/api/assets/missing-location-assets.ts +++ b/src/pages/api/assets/missing-location-assets.ts @@ -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, diff --git a/src/pages/api/assets/orphan-finder.ts b/src/pages/api/assets/orphan-finder.ts index 55c3e82..ffd682b 100644 --- a/src/pages/api/assets/orphan-finder.ts +++ b/src/pages/api/assets/orphan-finder.ts @@ -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, diff --git a/src/pages/api/share-link/[token]/assets.ts b/src/pages/api/share-link/[token]/assets.ts index a458a71..bfbc939 100644 --- a/src/pages/api/share-link/[token]/assets.ts +++ b/src/pages/api/share-link/[token]/assets.ts @@ -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(), diff --git a/src/pages/api/validate-permissions.ts b/src/pages/api/validate-permissions.ts index 37959f9..7e65f89 100644 --- a/src/pages/api/validate-permissions.ts +++ b/src/pages/api/validate-permissions.ts @@ -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 { diff --git a/src/schema/albumUsers.schema.ts b/src/schema/albumUsers.schema.ts new file mode 100644 index 0000000..13fee97 --- /dev/null +++ b/src/schema/albumUsers.schema.ts @@ -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(), +}); diff --git a/src/schema/albums.schema.ts b/src/schema/albums.schema.ts index aed3f0c..11f8d34 100644 --- a/src/schema/albums.schema.ts +++ b/src/schema/albums.schema.ts @@ -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'), diff --git a/src/schema/assets.schema.ts b/src/schema/assets.schema.ts index 0a12372..67b99b2 100644 --- a/src/schema/assets.schema.ts +++ b/src/schema/assets.schema.ts @@ -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' }), diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts index baedc41..38f3f67 100644 --- a/src/types/asset.d.ts +++ b/src/types/asset.d.ts @@ -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[];