Files
immich-power-tools/docs/plans/2026-04-06-orphan-finder.md

18 KiB

Orphan Asset Finder Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a page that finds "orphaned" assets — those not in albums, without detected people, missing location, or never favorited — with combinable checkbox filters, asset grid, and bulk actions (add to album, trash, delete).

Architecture: New API route queries Immich's DB via Drizzle with LEFT JOINs to detect orphan conditions. Frontend page follows the empty-videos pattern: filters in Header, AssetGrid with PhotoSelectionContext, FloatingBar with bulk actions. Sidebar entry added.

Tech Stack: Next.js API route, Drizzle ORM (PostgreSQL), React, Tailwind, existing shared components (PageLayout, Header, FloatingBar, AssetGrid, AlbumSelectorDialog, AlertDialog).


Task 1: Add API route for orphan assets

Files:

  • Create: src/pages/api/assets/orphan-finder.ts

Step 1: Create the API route

import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiRequest, NextApiResponse } from "next";
import { and, eq, isNull, isNotNull, sql, desc, asc, not } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { exif } from "@/schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assetFaces } from "@/schema/assetFaces.schema";
import { isFlipped } from "@/helpers/asset.helper";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const currentUser = await getCurrentUser(req);
  if (!currentUser) {
    return res.status(401).json({ message: "Unauthorized" });
  }

  const {
    notInAlbum,
    noPeople,
    noLocation,
    notFavorited,
    type,
    limit = "100",
    page = "1",
  } = req.query as Record<string, string>;

  const limitNum = parseInt(limit, 10);
  const pageNum = parseInt(page, 10);

  // Build conditions
  const conditions = [
    eq(assets.ownerId, currentUser.id),
    eq(assets.visibility, "timeline"),
    eq(assets.status, "active"),
    isNull(assets.deletedAt),
  ];

  if (type && type !== "ALL") {
    conditions.push(eq(assets.type, type));
  }

  if (notFavorited === "true") {
    conditions.push(eq(assets.isFavorite, false));
  }

  if (noLocation === "true") {
    conditions.push(isNull(exif.latitude));
  }

  // Subquery approach for notInAlbum and noPeople
  const notInAlbumCondition = notInAlbum === "true"
    ? sql`NOT EXISTS (SELECT 1 FROM "album_asset" aa WHERE aa."assetId" = ${assets.id})`
    : undefined;

  const noPeopleCondition = noPeople === "true"
    ? sql`NOT EXISTS (SELECT 1 FROM "asset_face" af WHERE af."assetId" = ${assets.id} AND af."personId" IS NOT NULL)`
    : undefined;

  if (notInAlbumCondition) conditions.push(notInAlbumCondition);
  if (noPeopleCondition) conditions.push(noPeopleCondition);

  // At least one orphan filter must be active
  const hasOrphanFilter = notInAlbum === "true" || noPeople === "true" || noLocation === "true" || notFavorited === "true";
  if (!hasOrphanFilter) {
    return res.status(400).json({ message: "At least one orphan filter is required" });
  }

  const dbAssets = await db
    .selectDistinctOn([assets.id], {
      id: assets.id,
      deviceId: assets.deviceId,
      type: assets.type,
      originalPath: assets.originalPath,
      isFavorite: assets.isFavorite,
      duration: assets.duration,
      originalFileName: assets.originalFileName,
      deletedAt: assets.deletedAt,
      localDateTime: assets.localDateTime,
      exifImageWidth: exif.exifImageWidth,
      exifImageHeight: exif.exifImageHeight,
      ownerId: assets.ownerId,
      dateTimeOriginal: exif.dateTimeOriginal,
      orientation: exif.orientation,
    })
    .from(assets)
    .leftJoin(exif, eq(assets.id, exif.assetId))
    .where(and(...conditions))
    .orderBy(assets.id, desc(assets.localDateTime))
    .limit(limitNum)
    .offset((pageNum - 1) * limitNum);

  const cleanedAssets = dbAssets.map((asset) => ({
    ...asset,
    exifImageHeight: isFlipped(asset?.orientation) ? asset?.exifImageWidth : asset?.exifImageHeight,
    exifImageWidth: isFlipped(asset?.orientation) ? asset?.exifImageHeight : asset?.exifImageWidth,
    orientation: asset?.orientation,
  }));

  return res.status(200).json(cleanedAssets);
}

Step 2: Commit

git add src/pages/api/assets/orphan-finder.ts
git commit -m "feat: add orphan finder API route"

Task 2: Add route constant and handler function

Files:

  • Modify: src/config/routes.ts
  • Modify: src/handlers/api/asset.handler.ts

Step 1: Add route constant

In src/config/routes.ts, add after LIST_EMPTY_VIDEOS_PATH:

export const LIST_ORPHAN_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/orphan-finder";

Step 2: Add handler function

In src/handlers/api/asset.handler.ts, add import and function:

Add LIST_ORPHAN_ASSETS_PATH to the imports from @/config/routes.

Add this function:

export interface IOrphanFilters {
  notInAlbum?: boolean;
  noPeople?: boolean;
  noLocation?: boolean;
  notFavorited?: boolean;
  type?: string;
  limit?: number;
  page?: number;
}

export const listOrphanAssets = async (filters: IOrphanFilters): Promise<IAsset[]> => {
  const params: Record<string, string> = {};
  if (filters.notInAlbum) params.notInAlbum = "true";
  if (filters.noPeople) params.noPeople = "true";
  if (filters.noLocation) params.noLocation = "true";
  if (filters.notFavorited) params.notFavorited = "true";
  if (filters.type) params.type = filters.type;
  if (filters.limit) params.limit = String(filters.limit);
  if (filters.page) params.page = String(filters.page);
  return API.get(LIST_ORPHAN_ASSETS_PATH, params).then((assets) => assets.map(cleanUpAsset));
}

Step 3: Commit

git add src/config/routes.ts src/handlers/api/asset.handler.ts
git commit -m "feat: add orphan finder route constant and handler"

Task 3: Create the Orphan Finder page

Files:

  • Create: src/pages/assets/orphan-finder.tsx

Step 1: Create the page

Follow the empty-videos pattern: PageLayout, Header with filters, PhotoSelectionContext, AssetGrid, FloatingBar with bulk actions.

import PageLayout from '@/components/layouts/PageLayout'
import Header from '@/components/shared/Header'
import Loader from '@/components/ui/loader'
import { listOrphanAssets, deleteAssets, IOrphanFilters } from '@/handlers/api/asset.handler'
import { addAssetToAlbum, createAlbumWithAssets } from '@/handlers/api/album.handler'
import { IAsset } from '@/types/asset'
import React, { useEffect, useState, useMemo } from 'react'
import { Camera, Trash2, Trash, Search } from 'lucide-react'
import { humanizeNumber } from '@/helpers/string.helper'
import PhotoSelectionContext, { IPhotoSelectionContext } from '@/contexts/PhotoSelectionContext'
import FloatingBar from '@/components/shared/FloatingBar'
import { Button } from '@/components/ui/button'
import { AlertDialog } from '@/components/ui/alert-dialog'
import AssetGrid from '@/components/shared/AssetGrid'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import AlbumSelectorDialog from '@/components/albums/AlbumSelectorDialog'
import { IAlbum } from '@/types/album'
import { toast } from '@/components/ui/use-toast'

export default function OrphanFinderPage() {
  const [assets, setAssets] = useState<IAsset[]>([])
  const [loading, setLoading] = useState(false)
  const [errorMessage, setErrorMessage] = useState('')

  const [filters, setFilters] = useState<IOrphanFilters>({
    notInAlbum: true,
    noPeople: false,
    noLocation: false,
    notFavorited: false,
    type: 'ALL',
    limit: 200,
    page: 1,
  })

  const [contextState, setContextState] = useState<IPhotoSelectionContext>({
    selectedIds: [],
    assets: [],
    config: {
      albumId: '',
      sort: "fileOriginalDate",
      sortOrder: "asc"
    },
    updateContext: (newConfig: Partial<IPhotoSelectionContext>) => {
      setContextState(prevState => ({
        ...prevState,
        ...newConfig,
        config: newConfig.config ? { ...prevState.config, ...newConfig.config } : prevState.config
      }));
    }
  });

  const selectedAssets = useMemo(() =>
    contextState.assets.filter((a) => contextState.selectedIds.includes(a.id)),
    [contextState.assets, contextState.selectedIds]
  );

  const hasActiveFilter = filters.notInAlbum || filters.noPeople || filters.noLocation || filters.notFavorited;

  const fetchOrphans = async () => {
    if (!hasActiveFilter) return;
    setLoading(true)
    setErrorMessage('')
    listOrphanAssets(filters)
      .then((fetchedAssets) => {
        setAssets(fetchedAssets)
        contextState.updateContext({ assets: fetchedAssets, selectedIds: [] })
      })
      .catch((error) => {
        setErrorMessage(error.message || 'Failed to load orphan assets')
      })
      .finally(() => {
        setLoading(false)
      })
  }

  useEffect(() => {
    fetchOrphans()
  }, [filters])

  const handleDelete = () => {
    return deleteAssets(contextState.selectedIds, { force: true }).then(() => {
      const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id));
      setAssets(newAssets)
      contextState.updateContext({ selectedIds: [], assets: newAssets });
    })
  }

  const handleTrash = () => {
    return deleteAssets(contextState.selectedIds, { force: false }).then(() => {
      const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id));
      setAssets(newAssets)
      contextState.updateContext({ selectedIds: [], assets: newAssets });
    })
  }

  const handleAddToAlbum = async (album: IAlbum) => {
    await addAssetToAlbum(album.id, contextState.selectedIds);
    toast({ title: "Success", description: `Added ${contextState.selectedIds.length} asset(s) to "${album.albumName}"` });
    // Re-fetch if "not in album" filter is active (assets may no longer be orphans)
    if (filters.notInAlbum) {
      fetchOrphans();
    }
  }

  const handleCreateAlbumWithAssets = async (data: { albumName: string }) => {
    return createAlbumWithAssets(data.albumName, contextState.selectedIds).then(() => {
      if (filters.notInAlbum) {
        fetchOrphans();
      }
    });
  }

  const handleFilterToggle = (key: keyof IOrphanFilters) => {
    setFilters(prev => ({ ...prev, [key]: !prev[key], page: 1 }));
  }

  const renderContent = () => {
    if (!hasActiveFilter) {
      return (
        <div className="text-center py-8 text-gray-500">
          Select at least one filter to find orphan assets.
        </div>
      );
    }
    if (loading) return <Loader />
    if (errorMessage) return <div className="text-red-500 p-4">{errorMessage}</div>
    return (
      <div className="p-4">
        {assets.length === 0 ? (
          <div className="text-center py-8 text-gray-500">
            <Search size={48} className="mx-auto text-gray-400 mb-4" />
            <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No orphan assets found</h3>
            <p>All your assets are well-organized with the current filters.</p>
          </div>
        ) : (
          <AssetGrid
            assets={assets}
            selectable={true}
            onSelectionChange={(ids) => contextState.updateContext({ selectedIds: ids })}
          />
        )}
      </div>
    )
  }

  return (
    <PageLayout className="!p-0 !mb-0 relative pb-20">
      <Header
        leftComponent="Orphan Finder"
        rightComponent={
          <div className="flex items-center gap-4">
            <div className="flex items-center gap-3">
              <div className="flex items-center gap-1.5">
                <Checkbox
                  id="notInAlbum"
                  checked={filters.notInAlbum}
                  onCheckedChange={() => handleFilterToggle('notInAlbum')}
                />
                <Label htmlFor="notInAlbum" className="text-sm cursor-pointer">No Album</Label>
              </div>
              <div className="flex items-center gap-1.5">
                <Checkbox
                  id="noPeople"
                  checked={filters.noPeople}
                  onCheckedChange={() => handleFilterToggle('noPeople')}
                />
                <Label htmlFor="noPeople" className="text-sm cursor-pointer">No People</Label>
              </div>
              <div className="flex items-center gap-1.5">
                <Checkbox
                  id="noLocation"
                  checked={filters.noLocation}
                  onCheckedChange={() => handleFilterToggle('noLocation')}
                />
                <Label htmlFor="noLocation" className="text-sm cursor-pointer">No Location</Label>
              </div>
              <div className="flex items-center gap-1.5">
                <Checkbox
                  id="notFavorited"
                  checked={filters.notFavorited}
                  onCheckedChange={() => handleFilterToggle('notFavorited')}
                />
                <Label htmlFor="notFavorited" className="text-sm cursor-pointer">Not Favorited</Label>
              </div>
            </div>
            <Select
              value={filters.type}
              onValueChange={(value) => setFilters(prev => ({ ...prev, type: value, page: 1 }))}
            >
              <SelectTrigger className="w-28 h-8">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="ALL">All Types</SelectItem>
                <SelectItem value="IMAGE">Images</SelectItem>
                <SelectItem value="VIDEO">Videos</SelectItem>
              </SelectContent>
            </Select>
            {!loading && assets.length > 0 && (
              <div className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-500">
                {humanizeNumber(assets.length)}
                <Camera className="w-4 h-4" />
              </div>
            )}
          </div>
        }
      />
      <PhotoSelectionContext.Provider value={{ ...contextState, updateContext: contextState.updateContext }}>
        {renderContent()}
        {selectedAssets.length > 0 && (
          <FloatingBar>
            <div className="flex items-center gap-2 justify-between w-full">
              <p className="text-sm text-muted-foreground">
                {contextState.selectedIds.length} Selected
              </p>
              <div className="flex items-center gap-2">
                {contextState.selectedIds.length === contextState.assets.length ? (
                  <Button variant="outline" size="sm" onClick={() => contextState.updateContext({ selectedIds: [] })}>
                    Unselect all
                  </Button>
                ) : (
                  <Button variant="outline" size="sm" onClick={() => contextState.updateContext({ selectedIds: contextState.assets.map((a) => a.id) })}>
                    Select all
                  </Button>
                )}
                <div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600" />
                <AlbumSelectorDialog
                  onSelected={handleAddToAlbum}
                  onSubmit={handleCreateAlbumWithAssets}
                />
                <div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600" />
                <AlertDialog
                  title="Move selected assets to Trash?"
                  description="This action will move the selected assets to trash. You can restore them later."
                  onConfirm={handleTrash}
                  disabled={contextState.selectedIds.length === 0}
                >
                  <Button variant="outline" size="sm" disabled={contextState.selectedIds.length === 0}>
                    <Trash className="w-4 h-4 mr-2" />
                    Trash
                  </Button>
                </AlertDialog>
                <AlertDialog
                  title="Delete the selected assets?"
                  description="This action will delete the selected assets and cannot be undone."
                  onConfirm={handleDelete}
                  disabled={contextState.selectedIds.length === 0}
                >
                  <Button variant="destructive" size="sm" disabled={contextState.selectedIds.length === 0}>
                    <Trash2 className="w-4 h-4 mr-2" />
                    Delete
                  </Button>
                </AlertDialog>
              </div>
            </div>
          </FloatingBar>
        )}
      </PhotoSelectionContext.Provider>
    </PageLayout>
  )
}

Step 2: Commit

git add src/pages/assets/orphan-finder.tsx
git commit -m "feat: add orphan finder page with filters and bulk actions"

Task 4: Add sidebar entry

Files:

  • Modify: src/config/constants/sidebarNavs.tsx

Step 1: Add the sidebar nav entry

Add PackageSearch to the lucide-react import, then add the entry after "Bulk Duplicate Finder":

{
  title: "Orphan Finder",
  link: "/assets/orphan-finder",
  icon: <PackageSearch className="h-4 w-4" />,
},

Step 2: Commit

git add src/config/constants/sidebarNavs.tsx
git commit -m "feat: add orphan finder to sidebar navigation"

Task 5: Verify createAlbumWithAssets handler exists

Files:

  • Possibly modify: src/handlers/api/album.handler.ts

Step 1: Check if createAlbumWithAssets exists in album handler

If it doesn't exist, add it. This function should create an album via Immich API and then add selected assets to it.

export const createAlbumWithAssets = async (albumName: string, assetIds: string[]) => {
  const album = await API.post(CREATE_ALBUM_PATH, { albumName, assetIds });
  return album;
}

Step 2: Commit if changes were needed

git add src/handlers/api/album.handler.ts
git commit -m "feat: add createAlbumWithAssets handler"

Task 6: Manual testing and fixes

Step 1: Run the dev server

bun run dev

Step 2: Test each filter combination

  • Visit /assets/orphan-finder
  • Toggle each checkbox individually, verify results
  • Combine filters, verify AND behavior
  • Test asset type dropdown
  • Select assets, verify floating bar appears
  • Test "Add to Album" flow
  • Test Trash and Delete flows
  • Verify sidebar link works

Step 3: Fix any issues found and commit

git add -A
git commit -m "fix: orphan finder polish and bug fixes"