diff --git a/app/admin/tasks/page.tsx b/app/admin/tasks/page.tsx
new file mode 100644
index 00000000..ccf03ff9
--- /dev/null
+++ b/app/admin/tasks/page.tsx
@@ -0,0 +1,5 @@
+import TasksPage from '~/components/admin/tasks/tasks-page'
+
+export default function AdminTasksPage() {
+ return
+}
diff --git a/components/admin/album/album-list.tsx b/components/admin/album/album-list.tsx
index 904d3c9b..b2e933b8 100644
--- a/components/admin/album/album-list.tsx
+++ b/components/admin/album/album-list.tsx
@@ -23,6 +23,7 @@ import { SquarePenIcon } from '~/components/icons/square-pen'
import { DeleteIcon } from '~/components/icons/delete'
import { useTranslations } from 'next-intl'
import { Badge } from '~/components/ui/badge'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export default function AlbumList(props : Readonly) {
const { data, mutate } = useSwrHydrated(props)
@@ -108,34 +109,46 @@ export default function AlbumList(props : Readonly) {
{album.sort}
-
-
)
-}
\ No newline at end of file
+}
diff --git a/components/admin/list/list-props.tsx b/components/admin/list/list-props.tsx
index 34801501..89814ba4 100644
--- a/components/admin/list/list-props.tsx
+++ b/components/admin/list/list-props.tsx
@@ -50,6 +50,7 @@ import {
} from '~/components/ui/popover'
import { RefreshCWIcon } from '~/components/icons/refresh-cw.tsx'
import { CircleChevronDownIcon } from '~/components/icons/circle-chevron-down.tsx'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export default function ListProps(props : Readonly) {
const [pageNum, setPageNum] = useState(1)
@@ -263,37 +264,54 @@ export default function ListProps(props : Readonly) {
-
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
+
+ {({ iconRef, triggerProps }) => (
-
+ )}
+
+
+
+ {({ iconRef, triggerProps }) => (
+
+
+
+ )}
+
@@ -500,24 +524,44 @@ export default function ListProps(props : Readonly) {
))}
- {
- if (pageNum > 1) {
- setPageNum(pageNum - 1)
- await mutate()
- }
- }}
- size={18}
- />
- {
- if (pageNum < Math.ceil((total ?? 0) / pageSize)) {
- setPageNum(pageNum + 1)
- await mutate()
- }
- }}
- size={18}
- />
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
}
@@ -525,4 +569,4 @@ export default function ListProps(props : Readonly) {
)
-}
\ No newline at end of file
+}
diff --git a/components/admin/tasks/tasks-page.tsx b/components/admin/tasks/tasks-page.tsx
new file mode 100644
index 00000000..4b216a63
--- /dev/null
+++ b/components/admin/tasks/tasks-page.tsx
@@ -0,0 +1,827 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import useSWR from 'swr'
+import { useLocale, useTranslations } from 'next-intl'
+import { ChevronRight, Clock3, LoaderCircle, MapPinned, RefreshCw, TriangleAlert, Wrench } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Badge } from '~/components/ui/badge'
+import { Button } from '~/components/ui/button'
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '~/components/ui/dialog'
+import { Progress } from '~/components/ui/progress'
+import { ScrollArea } from '~/components/ui/scroll-area'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
+import { cn } from '~/lib/utils'
+import { fetcher } from '~/lib/utils/fetcher'
+import type { AlbumType } from '~/types'
+import type {
+ AdminTaskError,
+ AdminTaskIssue,
+ AdminTaskPreviewCount,
+ AdminTaskRunBase,
+ AdminTaskRunDetail,
+ AdminTaskRunSummary,
+ AdminTaskRunsResponse,
+ AdminTaskScope,
+ AdminTaskStatus,
+} from '~/types/admin-tasks'
+import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA } from '~/types/admin-tasks'
+
+type ApiEnvelope = { code: number; message: string; data: T }
+type AlbumOption = Pick
+
+const DEFAULT_SCOPE: AdminTaskScope = { albumValue: 'all', showStatus: -1 }
+const HISTORY_RECENT_LIMIT = 10
+const HISTORY_VISIBLE_ROWS = 3
+const HISTORY_CARD_MIN_HEIGHT_REM = 10.75
+const HISTORY_SCROLL_MAX_HEIGHT = `${HISTORY_VISIBLE_ROWS * HISTORY_CARD_MIN_HEIGHT_REM}rem`
+const panelClass = 'show-up-motion relative overflow-hidden rounded-[1.7rem] border border-border/70 bg-card/82 shadow-sm backdrop-blur-sm'
+
+function statusClass(status: AdminTaskStatus) {
+ switch (status) {
+ case 'queued':
+ case 'cancelling':
+ return 'border-amber-400/35 bg-amber-500/12 text-amber-700 dark:text-amber-300'
+ case 'running':
+ return 'border-primary/30 bg-primary/10 text-primary'
+ case 'succeeded':
+ return 'border-emerald-400/35 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
+ case 'failed':
+ return 'border-rose-400/35 bg-rose-500/12 text-rose-700 dark:text-rose-300'
+ case 'cancelled':
+ default:
+ return 'border-border/80 bg-secondary text-secondary-foreground'
+ }
+}
+
+function issueClass(level: AdminTaskIssue['level']) {
+ if (level === 'error') {
+ return 'border-rose-400/35 bg-rose-500/12 text-rose-700 dark:text-rose-300'
+ }
+
+ if (level === 'warning') {
+ return 'border-amber-400/35 bg-amber-500/12 text-amber-700 dark:text-amber-300'
+ }
+
+ return 'border-slate-400/35 bg-slate-500/10 text-slate-700 dark:text-slate-300'
+}
+
+function progressOf(run: AdminTaskRunBase | null) {
+ if (!run || run.totalCount <= 0) return 0
+ return Math.min(100, Math.round((run.processedCount / run.totalCount) * 100))
+}
+
+function formatDate(value: string | null, formatter: Intl.DateTimeFormat, fallback: string) {
+ if (!value) return fallback
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? fallback : formatter.format(date)
+}
+
+async function readApi(response: Response): Promise {
+ const payload = (await response.json().catch(() => null)) as ApiEnvelope | null
+ if (!response.ok || !payload) throw new Error(payload?.message || 'Request failed')
+ return payload.data
+}
+
+async function getJson(url: string) {
+ const response = await fetch(url)
+ return readApi(response)
+}
+
+async function postJson(url: string, body?: unknown) {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
+ body: body ? JSON.stringify(body) : undefined,
+ })
+ return readApi(response)
+}
+
+function formatStageToken(stage: string) {
+ return stage.replace(/-/g, ' ').toUpperCase()
+}
+
+function formatCodeToken(code: string) {
+ return code.replace(/_/g, ' ').toUpperCase()
+}
+
+function normalizeIssueText(value: string) {
+ return value
+ .toLowerCase()
+ .replace(/[\s.,:;!?()[\]{}"'`_-]+/g, ' ')
+ .trim()
+}
+
+function isGenericTimeoutDetail(detail: string) {
+ const normalized = normalizeIssueText(detail)
+ return normalized === 'the operation was aborted due to timeout'
+ || normalized === 'this operation was aborted'
+ || normalized === 'signal is aborted without reason'
+}
+
+function issueDetailText(issue: AdminTaskIssue, httpLabel: string | null) {
+ const detail = issue.detail?.trim()
+ if (!detail) return null
+
+ const normalizedDetail = normalizeIssueText(detail)
+ if (!normalizedDetail) return null
+
+ if (normalizedDetail === normalizeIssueText(issue.summary)) return null
+ if (httpLabel && normalizedDetail === normalizeIssueText(httpLabel)) return null
+ if (issue.code === 'timeout' && isGenericTimeoutDetail(detail)) return null
+
+ return detail
+}
+
+function DetailMetaRow({ items }: { items: Array }) {
+ const filtered = items.filter((item): item is string => Boolean(item))
+ if (filtered.length === 0) return null
+
+ return (
+
+ {filtered.map((item, index) => (
+
+ {index > 0 ? : null}
+ {item}
+
+ ))}
+
+ )
+}
+
+function DetailSkeleton() {
+ return (
+
+ )
+}
+
+function ErrorDetailCard({
+ title,
+ error,
+ dateFormatter,
+ fallback,
+}: {
+ title: string
+ error: AdminTaskError
+ dateFormatter: Intl.DateTimeFormat
+ fallback: string
+}) {
+ return (
+
+
+
+
{title}
+
+
+
+
{error.message}
+ {error.detail ? (
+
+ {error.detail}
+
+ ) : null}
+
+
+ )
+}
+
+function IssueDetailCard({
+ issue,
+ dateFormatter,
+ fallback,
+ infoLabel,
+ warningLabel,
+ errorLabel,
+}: {
+ issue: AdminTaskIssue
+ dateFormatter: Intl.DateTimeFormat
+ fallback: string
+ infoLabel: string
+ warningLabel: string
+ errorLabel: string
+}) {
+ const httpLabel = issue.httpStatus
+ ? `HTTP ${issue.httpStatus}${issue.httpStatusText ? ` ${issue.httpStatusText}` : ''}`
+ : null
+ const detailText = issueDetailText(issue, httpLabel)
+
+ return (
+
+
+
+
+
{issue.imageTitle}
+
{issue.imageId}
+
+
+ {issue.level === 'error' ? errorLabel : issue.level === 'warning' ? warningLabel : infoLabel}
+
+
+
+
+
+
{issue.summary}
+
+ {detailText ? (
+
+ {detailText}
+
+ ) : null}
+
+
+ )
+}
+
+export default function TasksPage() {
+ const t = useTranslations('Tasks')
+ const tx = useTranslations()
+ const locale = useLocale()
+ const numberFormatter = new Intl.NumberFormat(locale)
+ const dateFormatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'medium' })
+ const [scope, setScope] = useState(DEFAULT_SCOPE)
+ const [selectedRunId, setSelectedRunId] = useState(null)
+ const [isStarting, setIsStarting] = useState(false)
+ const [isCancelling, setIsCancelling] = useState(false)
+ const kickingRef = useRef(false)
+ const previewQuery = new URLSearchParams({ albumValue: scope.albumValue, showStatus: String(scope.showStatus) }).toString()
+
+ const { data: albums, isLoading: albumsLoading } = useSWR('/api/v1/albums', fetcher)
+ const { data: runsData, isLoading: runsLoading, mutate: mutateRuns } = useSWR(
+ '/api/v1/tasks/runs',
+ getJson,
+ { refreshInterval: 5000 }
+ )
+ const { data: previewData, isLoading: previewLoading } = useSWR(
+ `/api/v1/tasks/preview-count?${previewQuery}`,
+ getJson
+ )
+ const { data: selectedRunDetail, isLoading: detailLoading, error: detailError } = useSWR(
+ selectedRunId ? `/api/v1/tasks/runs/${selectedRunId}` : null,
+ getJson
+ )
+
+ const activeRun = runsData?.activeRun ?? null
+ const recentRuns = runsData?.recentRuns ?? []
+ const selectedRunSummary = recentRuns.find((run) => run.id === selectedRunId) ?? null
+ const selectedRun = selectedRunDetail ?? selectedRunSummary
+
+ const albumName = (albumValue: string) =>
+ albumValue === 'all' ? tx('Words.all') : albums?.find((album) => album.album_value === albumValue)?.name || albumValue
+ const showLabel = (showStatus: AdminTaskScope['showStatus']) =>
+ showStatus === 0 ? tx('Words.public') : showStatus === 1 ? tx('Words.private') : tx('Words.all')
+ const statusLabel = (status: AdminTaskStatus) =>
+ status === 'queued'
+ ? t('statusQueued')
+ : status === 'running'
+ ? t('statusRunning')
+ : status === 'cancelling'
+ ? t('statusCancelling')
+ : status === 'succeeded'
+ ? t('statusSucceeded')
+ : status === 'failed'
+ ? t('statusFailed')
+ : t('statusCancelled')
+ const scopeLabel = (taskScope: AdminTaskScope) => `${albumName(taskScope.albumValue)} / ${showLabel(taskScope.showStatus)}`
+
+ async function refreshTaskData() {
+ await mutateRuns()
+ }
+
+ useEffect(() => {
+ if (!activeRun?.id || activeRun.status === 'cancelling') return
+
+ let cancelled = false
+
+ const runKick = async () => {
+ if (kickingRef.current) return
+ kickingRef.current = true
+
+ try {
+ await postJson(`/api/v1/tasks/runs/${activeRun.id}/kick`)
+ if (!cancelled) {
+ await mutateRuns()
+ }
+ } catch (error) {
+ if (!cancelled) toast.error(error instanceof Error ? error.message : t('kickFailed'))
+ } finally {
+ kickingRef.current = false
+ }
+ }
+
+ void runKick()
+ const timer = window.setInterval(() => {
+ void runKick()
+ }, 4500)
+
+ return () => {
+ cancelled = true
+ window.clearInterval(timer)
+ }
+ }, [activeRun?.id, activeRun?.status, mutateRuns, t])
+
+ async function handleStartTask() {
+ try {
+ setIsStarting(true)
+ await postJson('/api/v1/tasks/runs', {
+ taskKey: ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA,
+ scope,
+ })
+ toast.success(t('startSuccess'))
+ await refreshTaskData()
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : t('startFailed'))
+ } finally {
+ setIsStarting(false)
+ }
+ }
+
+ async function handleCancelTask() {
+ if (!activeRun?.id || activeRun.status === 'cancelling') return
+
+ try {
+ setIsCancelling(true)
+ await postJson(`/api/v1/tasks/runs/${activeRun.id}/cancel`)
+ toast.success(t('cancelSuccess'))
+ await refreshTaskData()
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : t('cancelFailed'))
+ } finally {
+ setIsCancelling(false)
+ }
+ }
+
+ const previewCount = previewData?.totalCount ?? 0
+ const activeProgress = progressOf(activeRun)
+ const cancelPending = isCancelling || activeRun?.status === 'cancelling'
+ const canStart = !activeRun && previewCount > 0 && !isStarting
+ const booting = albumsLoading || (!runsData && runsLoading)
+ const detailBooting = Boolean(selectedRunId) && detailLoading && !selectedRunDetail
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {activeRun ? (
+
+
+ {statusLabel(activeRun.status)}
+
+
+ {ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA}
+
+
+ {scopeLabel(activeRun.scope)}
+
+
+
+ ) : null}
+
+
+
+ {activeRun ? (
+
+
+
+
+
+ {t('progressLabel')}
+
+
+ {activeProgress}%
+
+
+
+
+
{t('matchedCount')}
+
{numberFormatter.format(activeRun.totalCount)}
+
+
+
{t('processedLabel')}
+
{numberFormatter.format(activeRun.processedCount)}
+
+
+
{t('startedAtLabel')}
+
+ {formatDate(activeRun.startedAt || activeRun.createdAt, dateFormatter, t('notAvailable'))}
+
+
+
+
+
+
+
+
+
+
{t('processedLabel')}
+
{numberFormatter.format(activeRun.processedCount)}
+
+
+
{t('successLabel')}
+
{numberFormatter.format(activeRun.successCount)}
+
+
+
{t('skippedLabel')}
+
{numberFormatter.format(activeRun.skippedCount)}
+
+
+
{t('failedLabel')}
+
{numberFormatter.format(activeRun.failedCount)}
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
{t('previewLabel')}
+
{previewLoading ? '...' : numberFormatter.format(previewCount)}
+
+
+
+
+
{t('currentSelection')}
+
+ {scopeLabel(scope)}
+
+
+
+
+
+ {!activeRun && previewCount < 1 && !previewLoading ? (
+
+
{t('noMatchHint')}
+ {albumsLoading ?
{t('albumsLoading')}
: null}
+
+ ) : null}
+ {activeRun || albumsLoading ? (
+
+ {activeRun ?
{t('runningHint')}
: null}
+ {albumsLoading ?
{t('albumsLoading')}
: null}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
{t('historyEyebrow')}
+
+
+ {t('historyRecentLimit', { count: HISTORY_RECENT_LIMIT })}
+
+
+
+
+ {recentRuns.length > 0 ? (
+
+
HISTORY_VISIBLE_ROWS ? { height: HISTORY_SCROLL_MAX_HEIGHT } : { maxHeight: HISTORY_SCROLL_MAX_HEIGHT }}>
+
+ {recentRuns.map((run, index) => (
+
+ ))}
+
+
+
+
+ ) : (
+
+ {booting ? t('loading') : t('noHistory')}
+
+ )}
+
+
+
+
+
+ { if (!open) setSelectedRunId(null) }}>
+ {selectedRunId ? (
+
+
+
+
+
+ {selectedRun ? (
+ <>
+
+
{t('detailEyebrow')}
+
{scopeLabel(selectedRun.scope)}
+
{t('detailDescription')}
+
+
+
+
+ {statusLabel(selectedRun.status)}
+
+
+ {formatDate(selectedRun.createdAt, dateFormatter, t('notAvailable'))}
+
+
+ {ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA}
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+ {selectedRun ? (
+
+
+
+
+
+
{t('progressLabel')}
+
{progressOf(selectedRun)}%
+
+
+
{t('matchedCount')}: {numberFormatter.format(selectedRun.totalCount)}
+
{t('processedLabel')}: {numberFormatter.format(selectedRun.processedCount)}
+
+
+
+
+
+
+
+
{t('successLabel')}
+
{numberFormatter.format(selectedRun.successCount)}
+
+
+
{t('skippedLabel')}
+
{numberFormatter.format(selectedRun.skippedCount)}
+
+
+
{t('failedLabel')}
+
{numberFormatter.format(selectedRun.failedCount)}
+
+
+
+
+ ) : (
+
+ )}
+
+ {detailBooting ? (
+
+ ) : detailError ? (
+
+ {detailError instanceof Error ? detailError.message : t('kickFailed')}
+
+ ) : selectedRunDetail ? (
+
+
+
+
+
+
{t('countsTitle')}
+
+
+
+
{t('matchedCount')}
+
{numberFormatter.format(selectedRunDetail.totalCount)}
+
+
+
{t('processedLabel')}
+
{numberFormatter.format(selectedRunDetail.processedCount)}
+
+
+
+
+
+
+
+
{t('timelineTitle')}
+
+
+
+
{t('startedAtLabel')}
+
{formatDate(selectedRunDetail.startedAt || selectedRunDetail.createdAt, dateFormatter, t('notAvailable'))}
+
+
+
{t('finishedAtLabel')}
+
{formatDate(selectedRunDetail.finishedAt, dateFormatter, t('unfinished'))}
+
+
+
+
+ {selectedRunDetail.lastError ? (
+
+ ) : null}
+
+
+
+
+
+
{t('issuesTitle')}
+
+
+ {selectedRunDetail.recentIssues.length > 0 ? (
+ selectedRunDetail.recentIssues.map((issue, index) => (
+
+ ))
+ ) : (
+
+ {t('noIssues')}
+
+ )}
+
+
+
+ ) : null}
+
+
+
+
+ ) : null}
+
+ >
+ )
+}
+
diff --git a/components/admin/upload/livephoto-file-upload.tsx b/components/admin/upload/livephoto-file-upload.tsx
index 2faad52c..3004239e 100644
--- a/components/admin/upload/livephoto-file-upload.tsx
+++ b/components/admin/upload/livephoto-file-upload.tsx
@@ -29,6 +29,7 @@ import { X } from 'lucide-react'
import { UploadIcon } from '~/components/icons/upload'
import { encodeBrowserThumbHash } from '~/lib/utils/blurhash-client'
import { useUploadConfig, STORAGE_OPTIONS } from '~/hooks/use-upload-config'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export default function LivephotoFileUpload() {
const {
@@ -339,11 +340,20 @@ export default function LivephotoFileUpload() {
size={20}
className="animate-spin cursor-not-allowed"
/> :
- submit()}
- aria-label={t('Button.submit')}
- />
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
}
{
@@ -379,15 +389,19 @@ export default function LivephotoFileUpload() {
multiple={false}
disabled={isUploadDisabled}
>
-
-
-
-
Drag & drop image here
-
- Or click to browse (max 1 files)
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+
+
+
Drag & drop image here
+
+ Or click to browse (max 1 files)
+
+
+
+ )}
+
{images.map((file, index) => (
@@ -411,15 +425,19 @@ export default function LivephotoFileUpload() {
multiple={false}
disabled={isUploadDisabled}
>
-
-
-
-
Drag & drop video here
-
- Or click to browse (max 1 files)
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+
+
+
Drag & drop video here
+
+ Or click to browse (max 1 files)
+
+
+
+ )}
+
{videos.map((file, index) => (
diff --git a/components/admin/upload/multiple-file-upload.tsx b/components/admin/upload/multiple-file-upload.tsx
index b030bc28..2e3debde 100644
--- a/components/admin/upload/multiple-file-upload.tsx
+++ b/components/admin/upload/multiple-file-upload.tsx
@@ -26,6 +26,7 @@ import { Button } from '~/components/ui/button'
import { X } from 'lucide-react'
import { encodeBrowserThumbHash } from '~/lib/utils/blurhash-client'
import { useUploadConfig, STORAGE_OPTIONS } from '~/hooks/use-upload-config'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export default function MultipleFileUpload() {
const {
@@ -242,18 +243,22 @@ export default function MultipleFileUpload() {
multiple={true}
disabled={isUploadDisabled}
>
-
-
-
-
{t('Upload.uploadTips1')}
-
- {t('Upload.uploadTips2')}
-
-
- {t('Upload.uploadTips4', { count: maxUploadFiles })}
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+
+
+
{t('Upload.uploadTips1')}
+
+ {t('Upload.uploadTips2')}
+
+
+ {t('Upload.uploadTips4', { count: maxUploadFiles })}
+
+
+
+ )}
+
{files.map((file, index) => (
diff --git a/components/admin/upload/simple-file-upload.tsx b/components/admin/upload/simple-file-upload.tsx
index dad1096a..eb5c9384 100644
--- a/components/admin/upload/simple-file-upload.tsx
+++ b/components/admin/upload/simple-file-upload.tsx
@@ -31,6 +31,7 @@ import { X } from 'lucide-react'
import { UploadIcon } from '~/components/icons/upload'
import { encodeBrowserThumbHash } from '~/lib/utils/blurhash-client.ts'
import { useUploadConfig, STORAGE_OPTIONS } from '~/hooks/use-upload-config'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export default function SimpleFileUpload() {
const {
@@ -296,11 +297,20 @@ export default function SimpleFileUpload() {
size={20}
className="animate-spin cursor-not-allowed"
/> :
- handleSubmit()}
- aria-label={t('Button.submit')}
- />
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
}
{
@@ -335,15 +345,19 @@ export default function SimpleFileUpload() {
multiple={false}
disabled={isUploadDisabled}
>
-
-
-
-
Drag & drop image here
-
- Or click to browse (max 1 files)
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+
+
+
Drag & drop image here
+
+ Or click to browse (max 1 files)
+
+
+
+ )}
+
{files.map((file, index) => (
diff --git a/components/album/preview-image-exif.tsx b/components/album/preview-image-exif.tsx
index fe69d3f0..3b7c2ba7 100644
--- a/components/album/preview-image-exif.tsx
+++ b/components/album/preview-image-exif.tsx
@@ -25,6 +25,7 @@ import { useTranslations } from 'next-intl'
import { ScrollArea } from '~/components/ui/scroll-area'
import HistogramChart from '~/components/album/histogram-chart'
import ToneAnalysis from '~/components/album/tone-analysis'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
// Row component for unified key-value display
function Row({ label, value }: { label: string; value: string | number | null | undefined }) {
@@ -88,12 +89,25 @@ export default function PreviewImageExif(props: Readonly) {
return (
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+
+
+ )}
+
{t('Exif.title')}
@@ -249,21 +263,26 @@ export default function PreviewImageExif(props: Readonly) {
{/* Copy EXIF button */}
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
diff --git a/components/album/preview-image.tsx b/components/album/preview-image.tsx
index 0036daba..1e2d9e83 100644
--- a/components/album/preview-image.tsx
+++ b/components/album/preview-image.tsx
@@ -31,6 +31,7 @@ import { Separator } from '~/components/ui/separator'
import { TelescopeIcon } from '~/components/icons/telescope'
import { FlaskIcon } from '~/components/icons/flask'
import { ScrollArea } from '~/components/ui/scroll-area'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
// Row component for unified key-value display
function Row({ label, value }: { label: string; value: string | number | null | undefined }) {
@@ -211,57 +212,74 @@ export default function PreviewImage(props: Readonly) {
{/* Header with title and close button */}
{props.data?.title}
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
{/* Action buttons */}
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
{configData?.find((item: Config) => item.config_key === 'custom_index_download_enable')?.config_value === 'true'
&& <>
{download ?
@@ -269,31 +287,43 @@ export default function PreviewImage(props: Readonly
) {
className={cn(exifIconClass, 'animate-spin cursor-not-allowed')}
size={20}
/> :
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
}
>
}
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
@@ -460,21 +490,26 @@ export default function PreviewImage(props: Readonly) {
{/* Copy EXIF button */}
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
diff --git a/components/album/tag-gallery.tsx b/components/album/tag-gallery.tsx
index 76a37f14..93a27fa6 100644
--- a/components/album/tag-gallery.tsx
+++ b/components/album/tag-gallery.tsx
@@ -12,6 +12,7 @@ import BlurImage from '~/components/album/blur-image'
import { SparklesIcon } from '~/components/icons/sparkles'
import { UndoIcon } from '~/components/icons/undo'
import { useRouter } from 'next-nprogress-bar'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
function renderNextImage(
_: RenderImageProps,
@@ -72,12 +73,21 @@ export default function TagGallery(props : Readonly) {
{props.album}
- router.back()}>
-
-
- {t('Button.goBack')}
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
@@ -101,4 +111,4 @@ export default function TagGallery(props : Readonly) {
)
-}
\ No newline at end of file
+}
diff --git a/components/icons/animated-trigger.tsx b/components/icons/animated-trigger.tsx
new file mode 100644
index 00000000..9dca8b60
--- /dev/null
+++ b/components/icons/animated-trigger.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import * as React from 'react'
+
+export type AnimatedIconHandle = {
+ startAnimation: () => void | Promise
+ stopAnimation: () => void | Promise
+}
+
+export type AnimatedIconProps = React.HTMLAttributes & {
+ size?: number
+}
+
+export type AnimatedIconComponent = React.ForwardRefExoticComponent<
+ React.PropsWithoutRef & React.RefAttributes
+>
+
+export type AnimatedTriggerProps = Pick<
+ React.HTMLAttributes,
+ 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur'
+>
+
+type AnimatedIconTriggerRenderProps = {
+ iconRef: React.RefObject
+ triggerProps: AnimatedTriggerProps
+}
+
+function composeEventHandler(
+ originalHandler: ((event: EventType) => void) | undefined,
+ animatedHandler: ((event: EventType) => void) | undefined,
+) {
+ return (event: EventType) => {
+ originalHandler?.(event)
+ animatedHandler?.(event)
+ }
+}
+
+export function mergeAnimatedTriggerProps>(
+ props: Props,
+ triggerProps: AnimatedTriggerProps,
+) {
+ return {
+ ...props,
+ onMouseEnter: composeEventHandler(
+ props.onMouseEnter as ((event: React.MouseEvent) => void) | undefined,
+ triggerProps.onMouseEnter,
+ ),
+ onMouseLeave: composeEventHandler(
+ props.onMouseLeave as ((event: React.MouseEvent) => void) | undefined,
+ triggerProps.onMouseLeave,
+ ),
+ onFocus: composeEventHandler(
+ props.onFocus as ((event: React.FocusEvent) => void) | undefined,
+ triggerProps.onFocus,
+ ),
+ onBlur: composeEventHandler(
+ props.onBlur as ((event: React.FocusEvent) => void) | undefined,
+ triggerProps.onBlur,
+ ),
+ }
+}
+
+export function AnimatedIconTrigger({
+ children,
+}: {
+ children: (props: AnimatedIconTriggerRenderProps) => React.ReactNode
+}) {
+ const iconRef = React.useRef(null)
+
+ const startAnimation = React.useCallback(() => {
+ void iconRef.current?.startAnimation()
+ }, [])
+
+ const stopAnimation = React.useCallback(() => {
+ void iconRef.current?.stopAnimation()
+ }, [])
+
+ const triggerProps = React.useMemo(
+ () => ({
+ onMouseEnter: () => startAnimation(),
+ onMouseLeave: () => stopAnimation(),
+ onFocus: () => startAnimation(),
+ onBlur: () => stopAnimation(),
+ }),
+ [startAnimation, stopAnimation],
+ )
+
+ // eslint-disable-next-line react-hooks/refs
+ return children({
+ iconRef,
+ triggerProps,
+ })
+}
diff --git a/components/icons/list-todo.tsx b/components/icons/list-todo.tsx
new file mode 100644
index 00000000..bfe23736
--- /dev/null
+++ b/components/icons/list-todo.tsx
@@ -0,0 +1,115 @@
+'use client'
+
+import type { Transition } from 'motion/react'
+import { motion, useAnimation } from 'motion/react'
+import type { HTMLAttributes } from 'react'
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'
+import { cn } from '~/lib/utils'
+
+export interface ListTodoIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface ListTodoIconProps extends HTMLAttributes {
+ size?: number;
+}
+
+const bounceTransition: Transition = {
+ type: 'spring',
+ stiffness: 220,
+ damping: 16,
+ mass: 0.9,
+}
+
+const ListTodoIcon = forwardRef(
+ ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation()
+ const isControlledRef = useRef(false)
+
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true
+
+ return {
+ startAnimation: async () => {
+ await controls.start('firstState')
+ await controls.start('secondState')
+ },
+ stopAnimation: () => controls.start('normal'),
+ }
+ })
+
+ const handleMouseEnter = useCallback(
+ async (e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ await controls.start('firstState')
+ await controls.start('secondState')
+ } else {
+ onMouseEnter?.(e)
+ }
+ },
+ [controls, onMouseEnter]
+ )
+
+ const handleMouseLeave = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isControlledRef.current) {
+ controls.start('normal')
+ } else {
+ onMouseLeave?.(e)
+ }
+ },
+ [controls, onMouseLeave]
+ )
+
+ return (
+
+ )
+ }
+)
+
+ListTodoIcon.displayName = 'ListTodoIcon'
+
+export { ListTodoIcon }
diff --git a/components/icons/sparkles.tsx b/components/icons/sparkles.tsx
index 01977111..b0396808 100644
--- a/components/icons/sparkles.tsx
+++ b/components/icons/sparkles.tsx
@@ -40,10 +40,7 @@ const starVariants: Variants = {
opacity: [0, 1, 0, 0, 0, 0, 1],
transition: {
duration: 2,
- type: 'spring',
- stiffness: 70,
- damping: 10,
- mass: 0.4,
+ ease: 'easeInOut',
},
}),
}
diff --git a/components/layout/admin/app-sidebar.tsx b/components/layout/admin/app-sidebar.tsx
index 2da3244e..5679660e 100644
--- a/components/layout/admin/app-sidebar.tsx
+++ b/components/layout/admin/app-sidebar.tsx
@@ -30,6 +30,8 @@ import { FingerprintIcon } from '~/components/icons/fingerprint'
import { LoaderPinwheelIcon } from '~/components/icons/loader-pinwheel'
import { KeySquareIcon } from '~/components/icons/key-square'
import { CalendarDaysIcon } from '~/components/icons/calendar-days'
+import { ListTodoIcon } from '~/components/icons/list-todo'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export function AppSidebar({ ...props }: React.ComponentProps) {
const router = useRouter()
@@ -57,6 +59,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
url: '/admin/album',
icon: GalleryThumbnailsIcon,
},
+ {
+ title: t('Link.tasks'),
+ url: '/admin/tasks',
+ icon: ListTodoIcon,
+ },
{
title: t('Link.about'),
url: '/admin/about',
@@ -110,12 +117,21 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
-
- router.push('/')}>
-
- {t('Login.goHome')}
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+ router.push('/'),
+ }, triggerProps)}
+ >
+
+ {t('Login.goHome')}
+
+
+ )}
+
diff --git a/components/layout/admin/nav-main.tsx b/components/layout/admin/nav-main.tsx
index 45b4ed0b..56543b61 100644
--- a/components/layout/admin/nav-main.tsx
+++ b/components/layout/admin/nav-main.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type LucideIcon } from 'lucide-react'
+import { AnimatedIconTrigger, type AnimatedIconComponent, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
import { Collapsible } from '~/components/ui/collapsible'
import {
SidebarGroup,
@@ -19,7 +19,7 @@ export function NavMain({
items: {
title: string
url: string
- icon?: LucideIcon
+ icon?: AnimatedIconComponent
isActive?: boolean
items?: {
title: string
@@ -37,25 +37,31 @@ export function NavMain({
菜单
{items.map((item) => (
-
-
- {
- setOpenMobile(false)
- router.push(item.url)
- }}>
- {item.icon && }
- {item.title}
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+
+ {
+ setOpenMobile(false)
+ router.push(item.url)
+ },
+ }, triggerProps)}
+ >
+ {item.icon && }
+ {item.title}
+
+
+
+ )}
+
))}
diff --git a/components/layout/admin/nav-projects.tsx b/components/layout/admin/nav-projects.tsx
index 7cbcd490..0a7a3b1a 100644
--- a/components/layout/admin/nav-projects.tsx
+++ b/components/layout/admin/nav-projects.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type LucideIcon } from 'lucide-react'
+import { AnimatedIconTrigger, type AnimatedIconComponent, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
import {
SidebarGroup,
SidebarGroupLabel,
@@ -20,7 +20,7 @@ export function NavProjects({
items?: {
name: string
url: string
- icon: LucideIcon
+ icon: AnimatedIconComponent
}[]
}
}) {
@@ -34,19 +34,26 @@ export function NavProjects({
{projects.title}
{projects?.items?.map((item) => (
-
- {
- setOpenMobile(false)
- router.push(item.url)
- }}>
-
- {item.name}
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+ {
+ setOpenMobile(false)
+ router.push(item.url)
+ },
+ }, triggerProps)}
+ >
+
+ {item.name}
+
+
+ )}
+
))}
diff --git a/components/layout/admin/nav-user.tsx b/components/layout/admin/nav-user.tsx
index 62114060..45650138 100644
--- a/components/layout/admin/nav-user.tsx
+++ b/components/layout/admin/nav-user.tsx
@@ -33,6 +33,7 @@ import { LanguagesIcon } from '~/components/icons/languages'
import { LogoutIcon } from '~/components/icons/logout'
import { ChevronsDownUpIcon } from '~/components/icons/chevrons-up-down'
import { useIsHydrated } from '~/hooks/use-is-hydrated'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export function NavUser() {
const { isMobile } = useSidebar()
@@ -48,22 +49,28 @@ export function NavUser() {
-
-
-
-
- CN
-
-
- {session?.user?.name}
- {session?.user?.email}
-
-
-
-
+
+ {({ iconRef, triggerProps }) => (
+
+
+
+
+ CN
+
+
+ {session?.user?.name}
+ {session?.user?.email}
+
+
+
+
+ )}
+
- {
- if (!isHydrated) {
- return
- }
- setTheme(resolvedTheme === 'light' ? 'dark' : 'light')
- }}
- >
- {!isHydrated ? : resolvedTheme === 'light' ? : }
- {themeToggleLabel}
-
+
+ {({ iconRef, triggerProps }) => (
+ {
+ if (!isHydrated) {
+ return
+ }
+ setTheme(resolvedTheme === 'light' ? 'dark' : 'light')
+ },
+ }, triggerProps)}
+ >
+ {!isHydrated ? : resolvedTheme === 'light' ? : }
+ {themeToggleLabel}
+
+ )}
+
- {t('Button.language')}
+
+ {({ iconRef, triggerProps }) => (
+
+
+ {t('Button.language')}
+
+ )}
+
setUserLocale('zh')}>简体中文
@@ -107,22 +131,31 @@ export function NavUser() {
- {
- try {
- await authClient.signOut({
- fetchOptions: {
- onSuccess: () => {
- location.replace('/login')
+
+ {({ iconRef, triggerProps }) => (
+ {
+ try {
+ await authClient.signOut({
+ fetchOptions: {
+ onSuccess: () => {
+ location.replace('/login')
+ },
+ },
+ })
+ } catch (e) {
+ console.error(e)
+ }
},
- },
- })
- } catch (e) {
- console.error(e)
- }
- }}>
-
- {t('Login.logout')}
-
+ }, triggerProps)}
+ >
+
+ {t('Login.logout')}
+
+ )}
+
diff --git a/components/layout/command.tsx b/components/layout/command.tsx
index b41694af..a9f8c39c 100644
--- a/components/layout/command.tsx
+++ b/components/layout/command.tsx
@@ -19,6 +19,7 @@ import { SunMediumIcon } from '~/components/icons/sun-medium.tsx'
import { UserIcon } from '~/components/icons/user.tsx'
import { authClient } from '~/server/auth/auth-client.ts'
import { useIsHydrated } from '~/hooks/use-is-hydrated'
+import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger'
export default function Command() {
const { command, setCommand } = useButtonStore(
@@ -44,39 +45,63 @@ export default function Command() {
{t('Command.noResults', { defaultValue: 'No results found.' })}
{session ?
- {
- router.push('/admin')
- setCommand(false)
- }}>
-
- {t('Link.dashboard')}
-
+
+ {({ iconRef, triggerProps }) => (
+ {
+ router.push('/admin')
+ setCommand(false)
+ },
+ }, triggerProps)}
+ >
+
+ {t('Link.dashboard')}
+
+ )}
+
:
- {
- router.push('/login')
- setCommand(false)
- }}>
-
- {t('Login.signIn')}
-
+
+ {({ iconRef, triggerProps }) => (
+ {
+ router.push('/login')
+ setCommand(false)
+ },
+ }, triggerProps)}
+ >
+
+ {t('Login.signIn')}
+
+ )}
+
}
- {
- if (!isHydrated) {
- return
- }
- setTheme(resolvedTheme === 'light' ? 'dark' : 'light')
- }}
- >
- {!isHydrated ? : resolvedTheme === 'light' ? : }
- {themeToggleLabel}
-
+
+ {({ iconRef, triggerProps }) => (
+ {
+ if (!isHydrated) {
+ return
+ }
+ setTheme(resolvedTheme === 'light' ? 'dark' : 'light')
+ },
+ }, triggerProps)}
+ >
+ {!isHydrated ? : resolvedTheme === 'light' ? : }
+ {themeToggleLabel}
+
+ )}
+
{
- onClick?.(event)
- toggleSidebar()
- }}
- {...props}
- >
-
- Toggle Sidebar
-
+
+ {({ iconRef, triggerProps }) => (
+
+ )}
+
)
}
diff --git a/hono/index.ts b/hono/index.ts
index 0df5e925..d8020e71 100644
--- a/hono/index.ts
+++ b/hono/index.ts
@@ -6,6 +6,7 @@ import images from '~/hono/images'
import albums from '~/hono/albums'
import openList from '~/hono/storage/open-list.ts'
import daily from '~/hono/daily'
+import tasks from '~/hono/tasks'
import { HTTPException } from 'hono/http-exception'
const route = new Hono()
@@ -25,5 +26,6 @@ route.route('/images', images)
route.route('/albums', albums)
route.route('/storage/open-list', openList)
route.route('/daily', daily)
+route.route('/tasks', tasks)
export default route
diff --git a/hono/tasks.ts b/hono/tasks.ts
new file mode 100644
index 00000000..9eec72c3
--- /dev/null
+++ b/hono/tasks.ts
@@ -0,0 +1,149 @@
+import 'server-only'
+
+import { Hono } from 'hono'
+import { HTTPException } from 'hono/http-exception'
+
+import {
+ cancelMetadataTaskRun,
+ createMetadataTaskRun,
+ getMetadataTaskPreviewCount,
+ getMetadataTaskRunDetail,
+ kickMetadataTaskRun,
+ listMetadataTaskRuns,
+ tickMetadataTaskRuns,
+} from '~/server/tasks/service'
+import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, normalizeMetadataTaskScope } from '~/types/admin-tasks'
+
+const app = new Hono()
+
+function ensureTaskKey(taskKey: unknown) {
+ if (taskKey !== ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA) {
+ throw new HTTPException(400, { message: 'Unsupported task key' })
+ }
+
+ return taskKey
+}
+
+function getScopeFromQuery(query: Record) {
+ return normalizeMetadataTaskScope({
+ albumValue: query.albumValue,
+ showStatus: query.showStatus,
+ })
+}
+
+function getScopeFromBody(body: Record | null) {
+ return normalizeMetadataTaskScope(body?.scope)
+}
+
+function rethrowTaskError(error: unknown) {
+ if (error instanceof HTTPException) {
+ throw error
+ }
+
+ const message = error instanceof Error ? error.message : 'Task request failed'
+
+ if (message === 'Another metadata task is already active') {
+ throw new HTTPException(409, { message })
+ }
+
+ if (message === 'No images matched the selected filters') {
+ throw new HTTPException(400, { message })
+ }
+
+ throw new HTTPException(500, { message, cause: error })
+}
+
+app.get('/preview-count', async (c) => {
+ try {
+ const scope = getScopeFromQuery(c.req.query())
+ const data = await getMetadataTaskPreviewCount(scope)
+ return c.json({ code: 200, message: 'Success', data })
+ } catch (error) {
+ rethrowTaskError(error)
+ }
+})
+
+app.get('/runs', async (c) => {
+ try {
+ const data = await listMetadataTaskRuns()
+ return c.json({ code: 200, message: 'Success', data })
+ } catch (error) {
+ rethrowTaskError(error)
+ }
+})
+
+app.get('/runs/:id', async (c) => {
+ try {
+ const data = await getMetadataTaskRunDetail(c.req.param('id'))
+
+ if (!data) {
+ throw new HTTPException(404, { message: 'Task run not found' })
+ }
+
+ return c.json({ code: 200, message: 'Success', data })
+ } catch (error) {
+ rethrowTaskError(error)
+ }
+})
+
+app.post('/runs', async (c) => {
+ const body = await c.req.json>().catch(() => null)
+
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
+ throw new HTTPException(400, { message: 'Invalid task request body' })
+ }
+
+ try {
+ ensureTaskKey(body.taskKey)
+ const scope = getScopeFromBody(body)
+ const data = await createMetadataTaskRun(scope)
+
+ if (!data) {
+ throw new HTTPException(409, { message: 'Task system is busy, please retry shortly' })
+ }
+
+ return c.json({ code: 200, message: 'Success', data }, 201)
+ } catch (error) {
+ rethrowTaskError(error)
+ }
+})
+
+app.post('/runs/:id/kick', async (c) => {
+ try {
+ const data = await kickMetadataTaskRun(c.req.param('id'))
+
+ if (!data) {
+ throw new HTTPException(404, { message: 'Task run not found' })
+ }
+
+ return c.json({ code: 200, message: 'Success', data })
+ } catch (error) {
+ rethrowTaskError(error)
+ }
+})
+
+app.post('/runs/:id/cancel', async (c) => {
+ try {
+ const data = await cancelMetadataTaskRun(c.req.param('id'))
+
+ if (!data) {
+ throw new HTTPException(404, { message: 'Task run not found' })
+ }
+
+ return c.json({ code: 200, message: 'Success', data })
+ } catch (error) {
+ rethrowTaskError(error)
+ }
+})
+
+app.post('/tick', async (c) => {
+ try {
+ const data = await tickMetadataTaskRuns()
+ return c.json({ code: 200, message: 'Success', data })
+ } catch (error) {
+ console.error('Task tick failed:', error)
+ rethrowTaskError(error)
+ }
+})
+
+export default app
diff --git a/messages/en.json b/messages/en.json
index 041e0b3d..90cbe42e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -40,7 +40,8 @@
"storages": "Storage Configuration",
"authenticator": "Two-Factor Authentication",
"passkey": "Passkey",
- "daily": "Daily Homepage"
+ "daily": "Daily Homepage",
+ "tasks": "Tasks"
},
"Passkey": {
"title": "Passkey Management",
@@ -210,7 +211,7 @@
},
"Upload": {
"simple": "Single File Upload",
- "livephoto": "LivePhoto Upload",
+ "livephoto": "LivePhoto Upload",
"multiple": "Multiple Files Upload",
"manualUpload": "Manual Upload",
"uploadTips1": "Click to upload file or drag file here",
@@ -224,7 +225,7 @@
"videoUrl": "LivePhoto Video URL",
"width": "Image Width px",
"height": "Image Height px",
- "lon": "Longitude",
+ "lon": "Longitude",
"lat": "Latitude",
"inputLon": "Enter Longitude",
"inputLat": "Enter Latitude",
@@ -490,5 +491,54 @@
"totalParticipating": "Participating Albums",
"saveSuccess": "Saved successfully!",
"saveFailed": "Save failed!"
+ },
+ "Tasks": {
+ "cancelling": "Cancelling",
+ "cancel": "Cancel task",
+ "progressLabel": "Progress",
+ "matchedCount": "Matched images",
+ "processedLabel": "Processed",
+ "successLabel": "Updated",
+ "skippedLabel": "Skipped",
+ "failedLabel": "Failed",
+ "startedAtLabel": "Started at",
+ "notAvailable": "N/A",
+ "previewLabel": "Preview count",
+ "currentSelection": "Current selection",
+ "starting": "Starting",
+ "start": "Start maintenance",
+ "noMatchHint": "No images match the current filters, so the task cannot be created.",
+ "runningHint": "One image metadata task is already active. A new run can start only after it fully stops.",
+ "albumsLoading": "Loading albums...",
+ "loading": "Loading...",
+ "historyEyebrow": "History",
+ "historyRecentLimit": "Latest {count} runs",
+ "noHistory": "No task history yet.",
+ "statusQueued": "Queued",
+ "statusRunning": "Running",
+ "statusCancelling": "Cancelling",
+ "statusSucceeded": "Succeeded",
+ "statusFailed": "Failed",
+ "statusCancelled": "Cancelled",
+ "startSuccess": "Task created successfully.",
+ "startFailed": "Failed to create task.",
+ "cancelSuccess": "Cancellation requested.",
+ "cancelFailed": "Failed to cancel task.",
+ "kickFailed": "Failed to advance task.",
+ "noIssues": "No logs were recorded for this run.",
+ "lastErrorTitle": "Last error",
+ "issuesTitle": "Recent logs",
+ "finishedAtLabel": "Finished at",
+ "issueInfo": "Info",
+ "issueWarning": "Warning",
+ "issueError": "Error",
+ "timelineTitle": "Timeline",
+ "detailView": "View details",
+ "detailDescription": "Inspect the scope, progress, outcome counts, timeline, and recent logs from this run.",
+ "detailEyebrow": "Run details",
+ "unfinished": "Still running",
+ "countsTitle": "Outcome counts"
}
}
+
+
diff --git a/messages/ja.json b/messages/ja.json
index 32dd54e0..06fd2f8f 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -40,7 +40,8 @@
"storages": "ストレージ設定",
"authenticator": "二要素認証",
"passkey": "Passkey",
- "daily": "デイリーホームページ"
+ "daily": "デイリーホームページ",
+ "tasks": "タスクセンター"
},
"Passkey": {
"title": "Passkey 管理",
@@ -490,5 +491,53 @@
"totalParticipating": "参加アルバム数",
"saveSuccess": "保存成功!",
"saveFailed": "保存失敗!"
+ },
+ "Tasks": {
+ "cancelling": "キャンセル中",
+ "cancel": "タスクをキャンセル",
+ "progressLabel": "進行状況",
+ "matchedCount": "対象画像",
+ "processedLabel": "処理済み",
+ "successLabel": "更新済み",
+ "skippedLabel": "スキップ",
+ "failedLabel": "失敗",
+ "startedAtLabel": "開始時刻",
+ "notAvailable": "なし",
+ "previewLabel": "対象件数",
+ "currentSelection": "現在の選択",
+ "starting": "開始中",
+ "start": "メンテナンスを開始",
+ "noMatchHint": "現在の条件に一致する画像がないため、タスクを作成できません。",
+ "runningHint": "画像メタデータタスクがすでにアクティブです。完全に停止するまで新しいタスクは開始できません。",
+ "albumsLoading": "アルバムを読み込み中...",
+ "loading": "読み込み中...",
+ "historyEyebrow": "履歴",
+ "historyRecentLimit": "最新 {count} 件",
+ "noHistory": "タスク履歴はまだありません。",
+ "statusQueued": "待機中",
+ "statusRunning": "実行中",
+ "statusCancelling": "キャンセル中",
+ "statusSucceeded": "完了",
+ "statusFailed": "失敗",
+ "statusCancelled": "キャンセル済み",
+ "startSuccess": "タスクを作成しました。",
+ "startFailed": "タスクの作成に失敗しました。",
+ "cancelSuccess": "キャンセル要求を送信しました。",
+ "cancelFailed": "タスクのキャンセルに失敗しました。",
+ "kickFailed": "タスクの進行に失敗しました。",
+ "noIssues": "この実行ではログは記録されませんでした。",
+ "lastErrorTitle": "最後のエラー",
+ "issuesTitle": "最近のログ",
+ "finishedAtLabel": "終了時刻",
+ "issueInfo": "情報",
+ "issueWarning": "警告",
+ "issueError": "エラー",
+ "timelineTitle": "タイムライン",
+ "detailView": "詳細を見る",
+ "detailDescription": "この実行の対象範囲、進行状況、結果、タイムライン、最近のログを確認します。",
+ "detailEyebrow": "実行詳細",
+ "unfinished": "継続中",
+ "countsTitle": "結果"
}
}
+
diff --git a/messages/zh-TW.json b/messages/zh-TW.json
index 26fbe192..eebc5bd4 100644
--- a/messages/zh-TW.json
+++ b/messages/zh-TW.json
@@ -40,7 +40,8 @@
"storages": "儲存配置",
"authenticator": "雙因素驗證",
"passkey": "Passkey",
- "daily": "Daily 首頁"
+ "daily": "Daily 首頁",
+ "tasks": "任務中心"
},
"Passkey": {
"title": "Passkey 管理",
@@ -490,5 +491,53 @@
"totalParticipating": "參與相簿數",
"saveSuccess": "儲存成功!",
"saveFailed": "儲存失敗!"
+ },
+ "Tasks": {
+ "cancelling": "正在取消",
+ "cancel": "取消任務",
+ "progressLabel": "處理進度",
+ "matchedCount": "命中圖片",
+ "processedLabel": "已處理",
+ "successLabel": "已更新",
+ "skippedLabel": "已跳過",
+ "failedLabel": "失敗",
+ "startedAtLabel": "開始時間",
+ "notAvailable": "暫無",
+ "previewLabel": "命中預覽",
+ "currentSelection": "當前選擇",
+ "starting": "正在啟動",
+ "start": "開始維護",
+ "noMatchHint": "當前篩選條件下沒有命中圖片,無法建立任務。",
+ "runningHint": "已有圖片中繼資料維護任務處於活動狀態,必須等待它完全停止後才能開始新任務。",
+ "albumsLoading": "相簿載入中...",
+ "loading": "載入中...",
+ "historyEyebrow": "歷史記錄",
+ "historyRecentLimit": "最近 {count} 筆",
+ "noHistory": "暫無任務記錄。",
+ "statusQueued": "排隊中",
+ "statusRunning": "執行中",
+ "statusCancelling": "取消中",
+ "statusSucceeded": "已完成",
+ "statusFailed": "已失敗",
+ "statusCancelled": "已取消",
+ "startSuccess": "任務建立成功。",
+ "startFailed": "任務建立失敗。",
+ "cancelSuccess": "已發送取消請求。",
+ "cancelFailed": "取消任務失敗。",
+ "kickFailed": "推進任務失敗。",
+ "noIssues": "本次任務沒有記錄到日誌。",
+ "lastErrorTitle": "最後一次錯誤",
+ "issuesTitle": "最近日誌",
+ "finishedAtLabel": "結束時間",
+ "issueInfo": "資訊",
+ "issueWarning": "警告",
+ "issueError": "錯誤",
+ "timelineTitle": "時間線",
+ "detailView": "查看詳情",
+ "detailDescription": "查看本次任務的範圍、進度、結果統計、時間線和最近日誌。",
+ "detailEyebrow": "任務詳情",
+ "unfinished": "未結束",
+ "countsTitle": "結果統計"
}
}
+
diff --git a/messages/zh.json b/messages/zh.json
index 4205f6f2..02478068 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -40,7 +40,8 @@
"storages": "存储配置",
"authenticator": "双因素验证",
"passkey": "Passkey",
- "daily": "Daily 首页"
+ "daily": "Daily 首页",
+ "tasks": "任务中心"
},
"Passkey": {
"title": "Passkey 管理",
@@ -490,5 +491,53 @@
"totalParticipating": "参与相册数",
"saveSuccess": "保存成功!",
"saveFailed": "保存失败!"
+ },
+ "Tasks": {
+ "cancelling": "正在取消",
+ "cancel": "取消任务",
+ "progressLabel": "处理进度",
+ "matchedCount": "命中图片",
+ "processedLabel": "已处理",
+ "successLabel": "已更新",
+ "skippedLabel": "已跳过",
+ "failedLabel": "失败",
+ "startedAtLabel": "开始时间",
+ "notAvailable": "暂无",
+ "previewLabel": "命中预览",
+ "currentSelection": "当前选择",
+ "starting": "正在启动",
+ "start": "开始维护",
+ "noMatchHint": "当前筛选条件下没有命中图片,无法创建任务。",
+ "runningHint": "已有图片元数据维护任务处于活动状态,必须等待它彻底停止后才能开始新任务。",
+ "albumsLoading": "相册加载中...",
+ "loading": "加载中...",
+ "historyEyebrow": "历史记录",
+ "historyRecentLimit": "最近 {count} 条",
+ "noHistory": "暂无任务记录。",
+ "statusQueued": "排队中",
+ "statusRunning": "执行中",
+ "statusCancelling": "取消中",
+ "statusSucceeded": "已完成",
+ "statusFailed": "已失败",
+ "statusCancelled": "已取消",
+ "startSuccess": "任务创建成功。",
+ "startFailed": "任务创建失败。",
+ "cancelSuccess": "已发送取消请求。",
+ "cancelFailed": "取消任务失败。",
+ "kickFailed": "推进任务失败。",
+ "noIssues": "本次任务没有记录到日志。",
+ "lastErrorTitle": "最后一次错误",
+ "issuesTitle": "最近日志",
+ "finishedAtLabel": "结束时间",
+ "issueInfo": "信息",
+ "issueWarning": "警告",
+ "issueError": "错误",
+ "timelineTitle": "时间线",
+ "detailView": "查看详情",
+ "detailDescription": "查看本次任务的范围、进度、结果统计、时间线和最近日志。",
+ "detailEyebrow": "任务详情",
+ "unfinished": "未结束",
+ "countsTitle": "结果统计"
}
}
+
diff --git a/package.json b/package.json
index 91d1de0d..81fc1344 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
"embla-carousel-react": "8.6.0",
"emblor": "1.4.8",
"exifreader": "4.36.2",
+ "@xmldom/xmldom": "^0.8.11",
"heic-to": "1.4.2",
"heic2any": "0.0.4",
"hono": "4.12.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4406b31d..21b3d561 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -120,6 +120,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: 1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@xmldom/xmldom':
+ specifier: ^0.8.11
+ version: 0.8.11
better-auth:
specifier: 1.5.0
version: 1.5.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(drizzle-orm@0.45.1(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(kysely@0.28.11)(prisma@6.19.2(typescript@5.9.3)))(mongodb@7.1.0)(next@16.2.1(@babel/core@7.27.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(prisma@6.19.2(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4032,6 +4035,10 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
+ '@xmldom/xmldom@0.8.11':
+ resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
+ engines: {node: '>=10.0.0'}
+
'@xmldom/xmldom@0.9.5':
resolution: {integrity: sha512-6g1EwSs8cr8JhP1iBxzyVAWM6BIDvx9Y3FZRIQiMDzgG43Pxi8YkWOZ0nQj2NHgNzgXDZbJewFx/n+YAvMZrfg==}
engines: {node: '>=14.6'}
@@ -11822,6 +11829,8 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
+ '@xmldom/xmldom@0.8.11': {}
+
'@xmldom/xmldom@0.9.5':
optional: true
diff --git a/prisma/migrations/20260323150247_add_admin_task_runs/migration.sql b/prisma/migrations/20260323150247_add_admin_task_runs/migration.sql
new file mode 100644
index 00000000..de03723b
--- /dev/null
+++ b/prisma/migrations/20260323150247_add_admin_task_runs/migration.sql
@@ -0,0 +1,28 @@
+-- CreateTable
+CREATE TABLE "admin_task_runs" (
+ "id" VARCHAR(50) NOT NULL,
+ "task_key" VARCHAR(100) NOT NULL,
+ "status" VARCHAR(50) NOT NULL DEFAULT 'queued',
+ "scope" JSON NOT NULL,
+ "total_count" INTEGER NOT NULL DEFAULT 0,
+ "processed_count" INTEGER NOT NULL DEFAULT 0,
+ "success_count" INTEGER NOT NULL DEFAULT 0,
+ "skipped_count" INTEGER NOT NULL DEFAULT 0,
+ "failed_count" INTEGER NOT NULL DEFAULT 0,
+ "next_cursor" VARCHAR(50),
+ "recent_issues" JSON NOT NULL DEFAULT '[]',
+ "last_error" JSON,
+ "lease_expires_at" TIMESTAMP,
+ "started_at" TIMESTAMP,
+ "finished_at" TIMESTAMP,
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP,
+
+ CONSTRAINT "admin_task_runs_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "admin_task_runs_task_key_status_idx" ON "admin_task_runs"("task_key", "status");
+
+-- CreateIndex
+CREATE INDEX "admin_task_runs_status_idx" ON "admin_task_runs"("status");
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
index 648c57fd..044d57cd 100644
--- a/prisma/migrations/migration_lock.toml
+++ b/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
-provider = "postgresql"
\ No newline at end of file
+provider = "postgresql"
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index aa46efb3..cd3ee957 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -173,3 +173,27 @@ model Passkey {
@@map("passkey")
}
+
+model AdminTaskRun {
+ id String @id @default(cuid()) @db.VarChar(50)
+ taskKey String @map("task_key") @db.VarChar(100)
+ status String @default("queued") @db.VarChar(50)
+ scope Json @db.Json
+ totalCount Int @default(0) @map("total_count")
+ processedCount Int @default(0) @map("processed_count")
+ successCount Int @default(0) @map("success_count")
+ skippedCount Int @default(0) @map("skipped_count")
+ failedCount Int @default(0) @map("failed_count")
+ nextCursor String? @map("next_cursor") @db.VarChar(50)
+ recentIssues Json @default("[]") @map("recent_issues") @db.Json
+ lastError Json? @map("last_error") @db.Json
+ leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamp
+ startedAt DateTime? @map("started_at") @db.Timestamp
+ finishedAt DateTime? @map("finished_at") @db.Timestamp
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamp
+ updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp
+
+ @@index([taskKey, status])
+ @@index([status])
+ @@map("admin_task_runs")
+}
diff --git a/server/lib/db.ts b/server/lib/db.ts
index 239f41e9..bbe706bd 100644
--- a/server/lib/db.ts
+++ b/server/lib/db.ts
@@ -12,4 +12,4 @@ const prisma = globalThis.prisma || prismaClientSingleton()
export const db = prisma
-globalThis.prisma = prisma
\ No newline at end of file
+globalThis.prisma = prisma
diff --git a/server/tasks/metadata-refresh.ts b/server/tasks/metadata-refresh.ts
new file mode 100644
index 00000000..517abdb7
--- /dev/null
+++ b/server/tasks/metadata-refresh.ts
@@ -0,0 +1,527 @@
+import 'server-only'
+
+import dayjs from 'dayjs'
+import ExifReader from 'exifreader'
+import sharp from 'sharp'
+import { DOMParser } from '@xmldom/xmldom'
+
+import type { ExifType } from '~/types'
+import type { AdminTaskIssue, AdminTaskStage } from '~/types/admin-tasks'
+import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA } from '~/types/admin-tasks'
+
+const exifDomParser = new DOMParser()
+const FETCH_TIMEOUT_MS = 20_000
+
+export class MetadataTaskCancelledError extends Error {
+ constructor(message = 'Task cancellation requested.') {
+ super(message)
+ this.name = 'MetadataTaskCancelledError'
+ }
+}
+
+const EMPTY_EXIF: ExifType = {
+ make: '',
+ model: '',
+ bits: '',
+ data_time: '',
+ exposure_time: '',
+ f_number: '',
+ exposure_program: '',
+ iso_speed_rating: '',
+ focal_length: '',
+ lens_specification: '',
+ lens_model: '',
+ exposure_mode: '',
+ cfa_pattern: '',
+ color_space: '',
+ white_balance: '',
+}
+
+type ExifTag = {
+ value?: unknown
+ description?: string
+}
+
+type ExifTags = Record
+
+type IssueInput = {
+ level: AdminTaskIssue['level']
+ stage: AdminTaskStage
+ code: string
+ summary: string
+ detail?: string | null
+ httpStatus?: number | null
+ httpStatusText?: string | null
+}
+
+type CoordinateUpdateResult = {
+ updates: Pick
+ issue?: AdminTaskIssue
+}
+
+export type MetadataRefreshImage = {
+ id: string
+ image_name: string | null
+ title: string | null
+ url: string | null
+ exif: unknown
+ width: number
+ height: number
+ lat: string | null
+ lon: string | null
+}
+
+export type MetadataRefreshUpdate = {
+ exif?: ExifType
+ width?: number
+ height?: number
+ lat?: string
+ lon?: string
+}
+
+export type MetadataRefreshResult = {
+ outcome: 'success' | 'skipped' | 'failed'
+ updates: MetadataRefreshUpdate
+ issues: AdminTaskIssue[]
+}
+
+function cleanString(value: unknown) {
+ return typeof value === 'string' ? value.trim() : ''
+}
+
+function cancellationReasonMessage(reason: unknown) {
+ if (reason instanceof Error && reason.message.trim()) return reason.message
+ if (typeof reason === 'string' && reason.trim()) return reason.trim()
+ return 'Task cancellation requested.'
+}
+
+export function createMetadataTaskCancelledError(reason?: unknown) {
+ return reason instanceof MetadataTaskCancelledError
+ ? reason
+ : new MetadataTaskCancelledError(cancellationReasonMessage(reason))
+}
+
+export function isMetadataTaskCancelledError(error: unknown): error is MetadataTaskCancelledError {
+ return error instanceof MetadataTaskCancelledError
+ || (error instanceof Error && error.name === 'MetadataTaskCancelledError')
+}
+
+export function throwIfMetadataTaskCancelled(signal?: AbortSignal) {
+ if (!signal?.aborted) return
+ throw createMetadataTaskCancelledError(signal.reason)
+}
+
+function nullableString(value: unknown) {
+ const normalized = cleanString(value)
+ return normalized || null
+}
+
+function pickImageTitle(image: Pick) {
+ return cleanString(image.title) || cleanString(image.image_name) || image.id
+}
+
+function toErrorDetail(error: unknown) {
+ if (error instanceof Error) return error.message
+ if (typeof error === 'string') return error
+ return null
+}
+
+function timeoutDetail(error: unknown) {
+ const fallback = `Fetch timed out after ${FETCH_TIMEOUT_MS / 1000} seconds.`
+ const detail = toErrorDetail(error)
+ if (!detail) return fallback
+
+ const normalized = detail.toLowerCase().replace(/\s+/g, ' ').trim()
+ if (
+ normalized === 'the operation was aborted due to timeout'
+ || normalized === 'this operation was aborted'
+ || normalized === 'signal is aborted without reason'
+ ) {
+ return fallback
+ }
+
+ return detail
+}
+
+function normalizeExif(input: Partial | null | undefined): ExifType | null {
+ const exif: ExifType = {
+ make: cleanString(input?.make),
+ model: cleanString(input?.model),
+ bits: cleanString(input?.bits),
+ data_time: cleanString(input?.data_time),
+ exposure_time: cleanString(input?.exposure_time),
+ f_number: cleanString(input?.f_number),
+ exposure_program: cleanString(input?.exposure_program),
+ iso_speed_rating: cleanString(input?.iso_speed_rating),
+ focal_length: cleanString(input?.focal_length),
+ lens_specification: cleanString(input?.lens_specification),
+ lens_model: cleanString(input?.lens_model),
+ exposure_mode: cleanString(input?.exposure_mode),
+ cfa_pattern: cleanString(input?.cfa_pattern),
+ color_space: cleanString(input?.color_space),
+ white_balance: cleanString(input?.white_balance),
+ }
+
+ if (exif.data_time && !dayjs(exif.data_time).isValid()) {
+ exif.data_time = ''
+ }
+
+ return Object.values(exif).some(Boolean) ? exif : null
+}
+
+function normalizeStoredExif(input: unknown) {
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
+ return null
+ }
+
+ return normalizeExif(input as Partial)
+}
+
+function areExifEqual(left: ExifType | null, right: ExifType | null) {
+ return JSON.stringify(left ?? EMPTY_EXIF) === JSON.stringify(right ?? EMPTY_EXIF)
+}
+
+function rationalToNumber(value: [number, number]) {
+ const [numerator, denominator] = value
+ if (!denominator) {
+ return null
+ }
+
+ return numerator / denominator
+}
+
+function normalizeCoordinate(value: number) {
+ const rounded = Number(value.toFixed(6))
+ return rounded.toString()
+}
+
+function gpsTripletToDecimal(
+ coordinates: [[number, number], [number, number], [number, number]] | undefined,
+ reference: unknown,
+ positiveRef: string,
+ negativeRef: string,
+) {
+ if (!coordinates || !Array.isArray(coordinates) || coordinates.length !== 3) {
+ return null
+ }
+
+ const [degrees, minutes, seconds] = coordinates
+ const degreeValue = rationalToNumber(degrees)
+ const minuteValue = rationalToNumber(minutes)
+ const secondValue = rationalToNumber(seconds)
+
+ if (
+ degreeValue === null
+ || minuteValue === null
+ || secondValue === null
+ ) {
+ return null
+ }
+
+ let decimal = degreeValue + minuteValue / 60 + secondValue / 3600
+ const ref = Array.isArray(reference) ? reference.join('') : cleanString(reference)
+
+ if (ref === negativeRef) {
+ decimal *= -1
+ } else if (ref !== positiveRef && ref !== negativeRef) {
+ return null
+ }
+
+ return decimal
+}
+
+function createIssue(
+ image: MetadataRefreshImage,
+ { level, stage, code, summary, detail = null, httpStatus = null, httpStatusText = null }: IssueInput,
+): AdminTaskIssue {
+ return {
+ imageId: image.id,
+ imageTitle: pickImageTitle(image),
+ taskKey: ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA,
+ level,
+ stage,
+ code,
+ summary,
+ detail,
+ httpStatus,
+ httpStatusText: nullableString(httpStatusText),
+ at: new Date().toISOString(),
+ }
+}
+
+function createSkippedReasonIssue(image: MetadataRefreshImage): AdminTaskIssue {
+ return createIssue(image, {
+ level: 'info',
+ stage: 'persist',
+ code: 'metadata_already_current',
+ summary: 'Metadata is already up to date.',
+ detail: 'The refreshed metadata matched the stored values, so no changes were required.',
+ })
+}
+
+function getCoordinateUpdates(tags: ExifTags | null, image: MetadataRefreshImage): CoordinateUpdateResult {
+ if (!tags) {
+ return { updates: {} }
+ }
+
+ const updates: Pick = {}
+ const detailParts: string[] = []
+
+ const hasLatitude = Boolean(tags.GPSLatitude || tags.GPSLatitudeRef)
+ const latitude = gpsTripletToDecimal(
+ tags.GPSLatitude?.value as [[number, number], [number, number], [number, number]] | undefined,
+ tags.GPSLatitudeRef?.value,
+ 'N',
+ 'S',
+ )
+ if (typeof latitude === 'number') {
+ if (latitude >= -90 && latitude <= 90) {
+ updates.lat = normalizeCoordinate(latitude)
+ } else {
+ detailParts.push(`Latitude ${latitude} is out of range.`)
+ }
+ } else if (hasLatitude) {
+ detailParts.push('Latitude could not be parsed from EXIF GPS tags.')
+ }
+
+ const hasLongitude = Boolean(tags.GPSLongitude || tags.GPSLongitudeRef)
+ const longitude = gpsTripletToDecimal(
+ tags.GPSLongitude?.value as [[number, number], [number, number], [number, number]] | undefined,
+ tags.GPSLongitudeRef?.value,
+ 'E',
+ 'W',
+ )
+ if (typeof longitude === 'number') {
+ if (longitude >= -180 && longitude <= 180) {
+ updates.lon = normalizeCoordinate(longitude)
+ } else {
+ detailParts.push(`Longitude ${longitude} is out of range.`)
+ }
+ } else if (hasLongitude) {
+ detailParts.push('Longitude could not be parsed from EXIF GPS tags.')
+ }
+
+ if (detailParts.length > 0) {
+ return {
+ updates,
+ issue: createIssue(image, {
+ level: 'warning',
+ stage: 'parse-exif',
+ code: 'invalid_gps_coordinates',
+ summary: 'Ignored invalid GPS coordinates.',
+ detail: detailParts.join(' '),
+ }),
+ }
+ }
+
+ return { updates }
+}
+
+function buildNormalizedExifFromTags(tags: ExifTags | null) {
+ if (!tags) {
+ return null
+ }
+
+ return normalizeExif({
+ make: tags.Make?.description,
+ model: tags.Model?.description,
+ bits: tags['Bits Per Sample']?.description,
+ data_time: tags.DateTimeOriginal?.description || tags.DateTime?.description,
+ exposure_time: tags.ExposureTime?.description,
+ f_number: tags.FNumber?.description,
+ exposure_program: tags.ExposureProgram?.description,
+ iso_speed_rating: tags.ISOSpeedRatings?.description,
+ focal_length: tags.FocalLength?.description,
+ lens_specification: tags.LensSpecification?.description,
+ lens_model: tags.LensModel?.description,
+ exposure_mode: tags.ExposureMode?.description,
+ cfa_pattern: tags.CFAPattern?.description,
+ color_space: tags.ColorSpace?.description,
+ white_balance: tags.WhiteBalance?.description,
+ })
+}
+
+function isTimeoutError(error: unknown) {
+ if (!(error instanceof Error)) return false
+ return error.name === 'TimeoutError' || error.name === 'AbortError' || /timeout/i.test(error.message)
+}
+
+export async function refreshImageMetadata(image: MetadataRefreshImage, signal?: AbortSignal): Promise {
+ throwIfMetadataTaskCancelled(signal)
+
+ if (!image.url) {
+ return {
+ outcome: 'failed',
+ updates: {},
+ issues: [createIssue(image, {
+ level: 'error',
+ stage: 'prepare',
+ code: 'missing_source_url',
+ summary: 'Missing source image URL.',
+ detail: 'The image record does not include an original image URL.',
+ })],
+ }
+ }
+
+ let buffer: Buffer
+ const fetchSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS)
+
+ try {
+ throwIfMetadataTaskCancelled(signal)
+ const response = await fetch(image.url, {
+ signal: fetchSignal,
+ cache: 'no-store',
+ })
+
+ if (!response.ok) {
+ return {
+ outcome: 'failed',
+ updates: {},
+ issues: [createIssue(image, {
+ level: 'error',
+ stage: 'fetch',
+ code: 'http_error',
+ summary: 'Failed to fetch the original image.',
+ detail: `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`,
+ httpStatus: response.status,
+ httpStatusText: response.statusText,
+ })],
+ }
+ }
+
+ buffer = Buffer.from(await response.arrayBuffer())
+ throwIfMetadataTaskCancelled(signal)
+ } catch (error) {
+ if (signal?.aborted || isMetadataTaskCancelledError(error)) {
+ throw createMetadataTaskCancelledError(signal?.reason ?? error)
+ }
+
+ const timeout = isTimeoutError(error)
+ return {
+ outcome: 'failed',
+ updates: {},
+ issues: [createIssue(image, {
+ level: 'error',
+ stage: 'fetch',
+ code: timeout ? 'timeout' : 'fetch_error',
+ summary: timeout ? 'Timed out while fetching the original image.' : 'Failed to fetch the original image.',
+ detail: timeout ? timeoutDetail(error) : (toErrorDetail(error) || 'Unknown fetch error.'),
+ })],
+ }
+ }
+
+ const issues: AdminTaskIssue[] = []
+ let tags: ExifTags | null = null
+ let exifCandidate: ExifType | null = null
+
+ try {
+ throwIfMetadataTaskCancelled(signal)
+ tags = await ExifReader.load(buffer, {
+ async: true,
+ domParser: exifDomParser,
+ }) as ExifTags
+ throwIfMetadataTaskCancelled(signal)
+ exifCandidate = buildNormalizedExifFromTags(tags)
+ } catch (error) {
+ if (signal?.aborted || isMetadataTaskCancelledError(error)) {
+ throw createMetadataTaskCancelledError(signal?.reason ?? error)
+ }
+
+ tags = null
+ issues.push(createIssue(image, {
+ level: 'warning',
+ stage: 'parse-exif',
+ code: 'exif_read_failed',
+ summary: 'Failed to parse EXIF metadata.',
+ detail: toErrorDetail(error) || 'ExifReader could not parse the image metadata payload.',
+ }))
+ }
+
+ let widthCandidate: number | undefined
+ let heightCandidate: number | undefined
+
+ try {
+ throwIfMetadataTaskCancelled(signal)
+ const metadata = await sharp(buffer).metadata()
+ throwIfMetadataTaskCancelled(signal)
+
+ if (metadata.width && metadata.width > 0) {
+ widthCandidate = metadata.width
+ }
+ if (metadata.height && metadata.height > 0) {
+ heightCandidate = metadata.height
+ }
+ } catch (error) {
+ if (signal?.aborted || isMetadataTaskCancelledError(error)) {
+ throw createMetadataTaskCancelledError(signal?.reason ?? error)
+ }
+
+ widthCandidate = undefined
+ heightCandidate = undefined
+ issues.push(createIssue(image, {
+ level: 'warning',
+ stage: 'read-dimensions',
+ code: 'read_dimensions_failed',
+ summary: 'Failed to read image dimensions.',
+ detail: toErrorDetail(error) || 'Sharp could not read image dimensions from the downloaded file.',
+ }))
+ }
+
+ throwIfMetadataTaskCancelled(signal)
+ const updates: MetadataRefreshUpdate = {}
+ const coordinateResult = getCoordinateUpdates(tags, image)
+ const storedExif = normalizeStoredExif(image.exif)
+
+ if (coordinateResult.issue) {
+ issues.push(coordinateResult.issue)
+ }
+
+ if (exifCandidate && !areExifEqual(storedExif, exifCandidate)) {
+ updates.exif = exifCandidate
+ }
+ if (typeof widthCandidate === 'number' && widthCandidate !== image.width) {
+ updates.width = widthCandidate
+ }
+ if (typeof heightCandidate === 'number' && heightCandidate !== image.height) {
+ updates.height = heightCandidate
+ }
+ if (coordinateResult.updates.lat && coordinateResult.updates.lat !== cleanString(image.lat)) {
+ updates.lat = coordinateResult.updates.lat
+ }
+ if (coordinateResult.updates.lon && coordinateResult.updates.lon !== cleanString(image.lon)) {
+ updates.lon = coordinateResult.updates.lon
+ }
+
+ if (Object.keys(updates).length > 0) {
+ return {
+ outcome: 'success',
+ updates,
+ issues,
+ }
+ }
+
+ const hasAnyParsedMetadata =
+ Boolean(exifCandidate) ||
+ typeof widthCandidate === 'number' ||
+ typeof heightCandidate === 'number' ||
+ Boolean(coordinateResult.updates.lat) ||
+ Boolean(coordinateResult.updates.lon)
+
+ if (!hasAnyParsedMetadata) {
+ issues.push(createIssue(image, {
+ level: 'warning',
+ stage: 'prepare',
+ code: 'no_valid_metadata',
+ summary: 'No valid metadata could be extracted.',
+ detail: 'The refreshed image did not yield usable EXIF, dimensions, or GPS coordinates.',
+ }))
+ } else {
+ issues.push(createSkippedReasonIssue(image))
+ }
+
+ return {
+ outcome: 'skipped',
+ updates: {},
+ issues,
+ }
+}
diff --git a/server/tasks/service.ts b/server/tasks/service.ts
new file mode 100644
index 00000000..3cf59e03
--- /dev/null
+++ b/server/tasks/service.ts
@@ -0,0 +1,750 @@
+import 'server-only'
+
+import { createId } from '@paralleldrive/cuid2'
+import { Prisma } from '@prisma/client'
+
+import { buildShowFilter } from '~/server/db/query/helpers'
+import { db } from '~/server/lib/db'
+import {
+ createMetadataTaskCancelledError,
+ isMetadataTaskCancelledError,
+ refreshImageMetadata,
+ throwIfMetadataTaskCancelled,
+ type MetadataRefreshImage,
+ type MetadataRefreshUpdate,
+} from '~/server/tasks/metadata-refresh'
+import type {
+ AdminTaskError,
+ AdminTaskIssue,
+ AdminTaskRunDetail,
+ AdminTaskRunSummary,
+ AdminTaskRunsResponse,
+ AdminTaskScope,
+ AdminTaskStage,
+ AdminTaskStatus,
+} from '~/types/admin-tasks'
+import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, normalizeMetadataTaskScope } from '~/types/admin-tasks'
+
+const METADATA_TASK_BATCH_SIZE = 10
+const METADATA_TASK_LOCK_ID = 42011
+const METADATA_TASK_LEASE_MS = 2 * 60 * 1000
+const ACTIVE_TASK_STATUSES: AdminTaskStatus[] = ['queued', 'running', 'cancelling']
+const RUNNABLE_TASK_STATUSES: AdminTaskStatus[] = ['queued', 'running']
+const RECENT_HISTORY_LIMIT = 10
+const VALID_TASK_STAGES = new Set(['prepare', 'fetch', 'parse-exif', 'read-dimensions', 'persist', 'process-batch', 'unknown'])
+
+declare const globalThis: {
+ metadataTaskAbortControllers?: Map;
+} & typeof global
+
+const taskAbortControllers = globalThis.metadataTaskAbortControllers || new Map()
+globalThis.metadataTaskAbortControllers = taskAbortControllers
+
+type TaskRunRecord = {
+ id: string
+ taskKey: string
+ status: string
+ scope: unknown
+ totalCount: number
+ processedCount: number
+ successCount: number
+ skippedCount: number
+ failedCount: number
+ nextCursor: string | null
+ recentIssues: unknown
+ lastError: unknown
+ leaseExpiresAt: Date | null
+ startedAt: Date | null
+ finishedAt: Date | null
+ createdAt: Date
+ updatedAt: Date | null
+}
+
+type IssueInput = {
+ level: AdminTaskIssue['level']
+ stage: AdminTaskStage
+ code: string
+ summary: string
+ detail?: string | null
+ httpStatus?: number | null
+ httpStatusText?: string | null
+}
+
+type TaskBatchProgress = {
+ processedCount: number
+ successCount: number
+ skippedCount: number
+ failedCount: number
+ nextCursor: string | null
+ issues: AdminTaskIssue[]
+}
+
+const taskRunSelect = Prisma.sql`
+ SELECT
+ "id",
+ "task_key" AS "taskKey",
+ "status",
+ "scope",
+ "total_count" AS "totalCount",
+ "processed_count" AS "processedCount",
+ "success_count" AS "successCount",
+ "skipped_count" AS "skippedCount",
+ "failed_count" AS "failedCount",
+ "next_cursor" AS "nextCursor",
+ "recent_issues" AS "recentIssues",
+ "last_error" AS "lastError",
+ "lease_expires_at" AS "leaseExpiresAt",
+ "started_at" AS "startedAt",
+ "finished_at" AS "finishedAt",
+ "created_at" AS "createdAt",
+ "updated_at" AS "updatedAt"
+ FROM "public"."admin_task_runs"
+`
+
+const taskRunReturning = Prisma.sql`
+ RETURNING
+ "id",
+ "task_key" AS "taskKey",
+ "status",
+ "scope",
+ "total_count" AS "totalCount",
+ "processed_count" AS "processedCount",
+ "success_count" AS "successCount",
+ "skipped_count" AS "skippedCount",
+ "failed_count" AS "failedCount",
+ "next_cursor" AS "nextCursor",
+ "recent_issues" AS "recentIssues",
+ "last_error" AS "lastError",
+ "lease_expires_at" AS "leaseExpiresAt",
+ "started_at" AS "startedAt",
+ "finished_at" AS "finishedAt",
+ "created_at" AS "createdAt",
+ "updated_at" AS "updatedAt"
+`
+
+function jsonValue(value: unknown) {
+ return Prisma.sql`${JSON.stringify(value)}::json`
+}
+
+function cleanString(value: unknown) {
+ return typeof value === 'string' ? value.trim() : ''
+}
+
+function nullableString(value: unknown) {
+ const normalized = cleanString(value)
+ return normalized || null
+}
+
+function normalizeTaskStage(value: unknown): AdminTaskStage {
+ const normalized = cleanString(value)
+ return VALID_TASK_STAGES.has(normalized as AdminTaskStage) ? normalized as AdminTaskStage : 'unknown'
+}
+
+function normalizeNullableNumber(value: unknown) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
+ return value
+}
+
+function serializeDate(value: Date | null | undefined) {
+ return value ? value.toISOString() : null
+}
+
+function fallbackIssueTime(record: Pick) {
+ return serializeDate(record.updatedAt) ?? record.createdAt.toISOString()
+}
+
+function normalizeTaskIssueArray(value: unknown, fallbackAt: string): AdminTaskIssue[] {
+ if (!Array.isArray(value)) return []
+
+ return value.flatMap((item) => {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return []
+
+ const rawIssue = item as Record
+ const imageId = cleanString(rawIssue.imageId)
+ const summary = cleanString(rawIssue.summary)
+ if (!imageId || !summary) return []
+
+ return [{
+ imageId,
+ imageTitle: cleanString(rawIssue.imageTitle) || imageId,
+ taskKey: ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA,
+ level: cleanString(rawIssue.level) === 'error'
+ ? 'error'
+ : cleanString(rawIssue.level) === 'info'
+ ? 'info'
+ : 'warning',
+ stage: normalizeTaskStage(rawIssue.stage),
+ code: cleanString(rawIssue.code) || 'unknown_error',
+ summary,
+ detail: nullableString(rawIssue.detail),
+ httpStatus: normalizeNullableNumber(rawIssue.httpStatus),
+ httpStatusText: nullableString(rawIssue.httpStatusText),
+ at: cleanString(rawIssue.at) || fallbackAt,
+ } satisfies AdminTaskIssue]
+ })
+}
+
+function normalizeTaskError(value: unknown, fallbackAt: string): AdminTaskError | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+
+ const rawError = value as Record
+ const message = cleanString(rawError.message)
+ if (!message) return null
+
+ return {
+ message,
+ detail: nullableString(rawError.detail),
+ stage: normalizeTaskStage(rawError.stage),
+ code: cleanString(rawError.code) || 'unknown_error',
+ at: cleanString(rawError.at) || fallbackAt,
+ }
+}
+
+function toTaskRunBase(record: TaskRunRecord) {
+ return {
+ id: record.id,
+ taskKey: record.taskKey as AdminTaskRunSummary['taskKey'],
+ status: record.status as AdminTaskStatus,
+ scope: normalizeMetadataTaskScope(record.scope),
+ totalCount: record.totalCount,
+ processedCount: record.processedCount,
+ successCount: record.successCount,
+ skippedCount: record.skippedCount,
+ failedCount: record.failedCount,
+ nextCursor: record.nextCursor,
+ leaseExpiresAt: serializeDate(record.leaseExpiresAt),
+ startedAt: serializeDate(record.startedAt),
+ finishedAt: serializeDate(record.finishedAt),
+ createdAt: record.createdAt.toISOString(),
+ updatedAt: serializeDate(record.updatedAt),
+ }
+}
+
+function toAdminTaskRunSummary(record: TaskRunRecord | null): AdminTaskRunSummary | null {
+ if (!record) return null
+ return toTaskRunBase(record)
+}
+
+function toAdminTaskRunDetail(record: TaskRunRecord | null): AdminTaskRunDetail | null {
+ if (!record) return null
+
+ const fallbackAt = fallbackIssueTime(record)
+ return {
+ ...toTaskRunBase(record),
+ recentIssues: normalizeTaskIssueArray(record.recentIssues, fallbackAt),
+ lastError: normalizeTaskError(record.lastError, fallbackAt),
+ }
+}
+
+function createTaskError(message: string, options?: { detail?: string | null; stage?: AdminTaskStage; code?: string }): AdminTaskError {
+ return {
+ message,
+ detail: options?.detail ?? null,
+ stage: options?.stage ?? 'process-batch',
+ code: options?.code ?? 'batch_failed',
+ at: new Date().toISOString(),
+ }
+}
+
+function mergeRecentIssues(existing: unknown, incoming: AdminTaskIssue[], fallbackAt: string) {
+ if (incoming.length === 0) return normalizeTaskIssueArray(existing, fallbackAt)
+ return [...normalizeTaskIssueArray(existing, fallbackAt), ...incoming].slice(-20)
+}
+
+function buildTaskScopeQuery(scope: AdminTaskScope) {
+ if (scope.albumValue !== 'all') {
+ return {
+ from: Prisma.sql`
+ FROM "public"."images" AS image
+ INNER JOIN "public"."images_albums_relation" AS relation ON image.id = relation."imageId"
+ INNER JOIN "public"."albums" AS album ON relation.album_value = album.album_value
+ `,
+ where: Prisma.sql`
+ WHERE image.del = 0 AND album.del = 0 AND album.album_value = ${scope.albumValue}
+ ${buildShowFilter(scope.showStatus)}
+ `,
+ }
+ }
+
+ return {
+ from: Prisma.sql`FROM "public"."images" AS image`,
+ where: Prisma.sql`WHERE image.del = 0 ${buildShowFilter(scope.showStatus)}`,
+ }
+}
+
+function imageTitle(image: Pick) {
+ return cleanString(image.title) || cleanString(image.image_name) || image.id
+}
+
+function createImageIssue(image: MetadataRefreshImage, input: IssueInput): AdminTaskIssue {
+ return {
+ imageId: image.id,
+ imageTitle: imageTitle(image),
+ taskKey: ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA,
+ level: input.level,
+ stage: input.stage,
+ code: input.code,
+ summary: input.summary,
+ detail: input.detail ?? null,
+ httpStatus: input.httpStatus ?? null,
+ httpStatusText: nullableString(input.httpStatusText),
+ at: new Date().toISOString(),
+ }
+}
+
+function unknownErrorDetail(error: unknown) {
+ if (error instanceof Error) return error.message
+ if (typeof error === 'string') return error
+ return 'Unknown error.'
+}
+
+function hasActiveLease(record: Pick, now = new Date()) {
+ return Boolean(record.leaseExpiresAt && record.leaseExpiresAt.getTime() > now.getTime())
+}
+
+function registerTaskAbortController(runId: string) {
+ const controller = new AbortController()
+ taskAbortControllers.set(runId, controller)
+ return controller
+}
+
+function clearTaskAbortController(runId: string, controller?: AbortController) {
+ const current = taskAbortControllers.get(runId)
+ if (!current) return
+ if (controller && current !== controller) return
+ taskAbortControllers.delete(runId)
+}
+
+function abortTaskController(controller: AbortController, reason?: unknown) {
+ if (controller.signal.aborted) return
+ controller.abort(createMetadataTaskCancelledError(reason))
+}
+
+function abortTaskRunInProcess(runId: string, reason?: unknown) {
+ const controller = taskAbortControllers.get(runId)
+ if (!controller) return false
+ abortTaskController(controller, reason)
+ return true
+}
+
+async function countImagesForScope(scope: AdminTaskScope) {
+ const query = buildTaskScopeQuery(scope)
+ const result = await db.$queryRaw>`
+ SELECT COUNT(DISTINCT image.id) AS total
+ ${query.from}
+ ${query.where}
+ `
+ return Number(result[0]?.total ?? 0)
+}
+
+async function fetchImagesBatchForScope(scope: AdminTaskScope, nextCursor: string | null) {
+ const query = buildTaskScopeQuery(scope)
+ const cursorFilter = nextCursor ? Prisma.sql`AND image.id > ${nextCursor}` : Prisma.empty
+
+ return db.$queryRaw`
+ SELECT DISTINCT ON (image.id)
+ image.id,
+ image.image_name,
+ image.title,
+ image.url,
+ image.exif,
+ image.width,
+ image.height,
+ image.lat,
+ image.lon
+ ${query.from}
+ ${query.where}
+ ${cursorFilter}
+ ORDER BY image.id ASC
+ LIMIT ${METADATA_TASK_BATCH_SIZE}
+ `
+}
+
+async function updateImageMetadataFields(imageId: string, updates: MetadataRefreshUpdate) {
+ const data: Prisma.ImagesUpdateInput = { updatedAt: new Date() }
+ if (updates.exif) data.exif = updates.exif
+ if (typeof updates.width === 'number') data.width = updates.width
+ if (typeof updates.height === 'number') data.height = updates.height
+ if (typeof updates.lat === 'string') data.lat = updates.lat
+ if (typeof updates.lon === 'string') data.lon = updates.lon
+
+ return db.images.update({ where: { id: imageId }, data })
+}
+
+async function withTaskLock(callback: () => Promise) {
+ const lockResult = await db.$queryRaw>`
+ SELECT pg_try_advisory_lock(${METADATA_TASK_LOCK_ID})
+ `
+
+ if (!lockResult[0]?.pg_try_advisory_lock) return null
+
+ try {
+ return await callback()
+ } finally {
+ await db.$executeRaw`SELECT pg_advisory_unlock(${METADATA_TASK_LOCK_ID})`
+ }
+}
+
+async function findTaskRunById(runId: string) {
+ const rows = await db.$queryRaw`
+ ${taskRunSelect}
+ WHERE "id" = ${runId}
+ LIMIT 1
+ `
+ return rows[0] ?? null
+}
+
+async function findActiveTaskRun(order: 'asc' | 'desc' = 'desc') {
+ const orderSql = Prisma.raw(order.toUpperCase())
+ const rows = await db.$queryRaw`
+ ${taskRunSelect}
+ WHERE "task_key" = ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA}
+ AND "status" IN (${Prisma.join(ACTIVE_TASK_STATUSES)})
+ ORDER BY "created_at" ${orderSql}
+ LIMIT 1
+ `
+ return rows[0] ?? null
+}
+
+async function findRunnableTaskRun(order: 'asc' | 'desc' = 'desc') {
+ const orderSql = Prisma.raw(order.toUpperCase())
+ const rows = await db.$queryRaw`
+ ${taskRunSelect}
+ WHERE "task_key" = ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA}
+ AND "status" IN (${Prisma.join(RUNNABLE_TASK_STATUSES)})
+ ORDER BY "created_at" ${orderSql}
+ LIMIT 1
+ `
+ return rows[0] ?? null
+}
+
+async function listRecentTaskRunRecords(limit: number, excludeRunId: string | null) {
+ const excludeSql = excludeRunId ? Prisma.sql`AND "id" <> ${excludeRunId}` : Prisma.empty
+
+ return db.$queryRaw`
+ ${taskRunSelect}
+ WHERE "task_key" = ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA}
+ ${excludeSql}
+ ORDER BY "created_at" DESC
+ LIMIT ${limit}
+ `
+}
+
+async function leaseTaskRun(runId: string) {
+ const now = new Date()
+ const leaseExpiresAt = new Date(now.getTime() + METADATA_TASK_LEASE_MS)
+
+ const rows = await db.$queryRaw`
+ UPDATE "public"."admin_task_runs"
+ SET
+ "status" = 'running',
+ "started_at" = COALESCE("started_at", ${now}),
+ "lease_expires_at" = ${leaseExpiresAt},
+ "last_error" = NULL,
+ "updated_at" = ${now}
+ WHERE "id" = ${runId}
+ AND "status" IN (${Prisma.join(RUNNABLE_TASK_STATUSES)})
+ AND ("lease_expires_at" IS NULL OR "lease_expires_at" <= ${now})
+ ${taskRunReturning}
+ `
+
+ return rows[0] ?? null
+}
+
+async function finalizeTaskRunAsCancelled(runId: string, now = new Date()) {
+ const rows = await db.$queryRaw`
+ UPDATE "public"."admin_task_runs"
+ SET
+ "status" = 'cancelled',
+ "finished_at" = COALESCE("finished_at", ${now}),
+ "lease_expires_at" = NULL,
+ "updated_at" = ${now}
+ WHERE "id" = ${runId}
+ ${taskRunReturning}
+ `
+
+ return rows[0] ?? null
+}
+
+async function ensureTaskRunCanContinue(runId: string, controller: AbortController) {
+ throwIfMetadataTaskCancelled(controller.signal)
+
+ const record = await findTaskRunById(runId)
+ if (!record) {
+ abortTaskController(controller, 'Task run no longer exists.')
+ throw createMetadataTaskCancelledError('Task run no longer exists.')
+ }
+
+ if (record.status === 'cancelling' || record.status === 'cancelled') {
+ abortTaskController(controller, 'Task cancellation requested.')
+ throw createMetadataTaskCancelledError('Task cancellation requested.')
+ }
+
+ throwIfMetadataTaskCancelled(controller.signal)
+ return record
+}
+
+async function finalizeFailedTaskRun(runId: string, progress: TaskBatchProgress, error: unknown) {
+ const currentRecord = await findTaskRunById(runId)
+ if (!currentRecord) return null
+
+ const now = new Date()
+ const nextCursor = progress.processedCount > 0 ? progress.nextCursor : currentRecord.nextCursor
+ const batchError = jsonValue(createTaskError('Failed to process task batch.', {
+ detail: unknownErrorDetail(error),
+ stage: 'process-batch',
+ code: 'batch_failed',
+ }))
+
+ const rows = await db.$queryRaw`
+ UPDATE "public"."admin_task_runs"
+ SET
+ "processed_count" = "processed_count" + ${progress.processedCount},
+ "success_count" = "success_count" + ${progress.successCount},
+ "skipped_count" = "skipped_count" + ${progress.skippedCount},
+ "failed_count" = "failed_count" + ${progress.failedCount},
+ "next_cursor" = ${nextCursor},
+ "recent_issues" = ${jsonValue(mergeRecentIssues(currentRecord.recentIssues, progress.issues, fallbackIssueTime(currentRecord)))},
+ "lease_expires_at" = NULL,
+ "status" = CASE
+ WHEN "status" IN ('cancelling', 'cancelled') THEN 'cancelled'
+ ELSE 'failed'
+ END,
+ "finished_at" = COALESCE("finished_at", ${now}),
+ "last_error" = CASE
+ WHEN "status" IN ('cancelling', 'cancelled') THEN "last_error"
+ ELSE ${batchError}
+ END,
+ "updated_at" = ${now}
+ WHERE "id" = ${runId}
+ ${taskRunReturning}
+ `
+
+ return toAdminTaskRunSummary(rows[0] ?? null)
+}
+
+async function finalizeTaskRunBatch(runId: string, progress: TaskBatchProgress, batchLength: number, stoppedByCancellation: boolean) {
+ const currentRecord = await findTaskRunById(runId)
+ if (!currentRecord) return null
+
+ const finalProcessedCount = currentRecord.processedCount + progress.processedCount
+ const nextCursor = progress.processedCount > 0 ? progress.nextCursor : currentRecord.nextCursor
+ const isCompleted = !stoppedByCancellation && (
+ finalProcessedCount >= currentRecord.totalCount
+ || (batchLength < METADATA_TASK_BATCH_SIZE && progress.processedCount === batchLength)
+ )
+ const now = new Date()
+
+ const rows = await db.$queryRaw`
+ UPDATE "public"."admin_task_runs"
+ SET
+ "processed_count" = "processed_count" + ${progress.processedCount},
+ "success_count" = "success_count" + ${progress.successCount},
+ "skipped_count" = "skipped_count" + ${progress.skippedCount},
+ "failed_count" = "failed_count" + ${progress.failedCount},
+ "next_cursor" = ${nextCursor},
+ "recent_issues" = ${jsonValue(mergeRecentIssues(currentRecord.recentIssues, progress.issues, fallbackIssueTime(currentRecord)))},
+ "lease_expires_at" = NULL,
+ "status" = CASE
+ WHEN "status" IN ('cancelling', 'cancelled') THEN 'cancelled'
+ WHEN ${isCompleted} THEN 'succeeded'
+ ELSE 'running'
+ END,
+ "finished_at" = CASE
+ WHEN "status" IN ('cancelling', 'cancelled') OR ${isCompleted} THEN COALESCE("finished_at", ${now})
+ ELSE NULL
+ END,
+ "updated_at" = ${now}
+ WHERE "id" = ${runId}
+ ${taskRunReturning}
+ `
+
+ return toAdminTaskRunSummary(rows[0] ?? null)
+}
+
+export async function getMetadataTaskPreviewCount(scope: AdminTaskScope) {
+ return { totalCount: await countImagesForScope(scope) }
+}
+
+export async function listMetadataTaskRuns(): Promise {
+ const activeRecord = await findActiveTaskRun('desc')
+ const activeRun = toAdminTaskRunSummary(activeRecord)
+ const historyRecords = await listRecentTaskRunRecords(RECENT_HISTORY_LIMIT, activeRecord?.id ?? null)
+
+ return {
+ activeRun,
+ recentRuns: historyRecords
+ .map((record) => toAdminTaskRunSummary(record))
+ .filter(Boolean) as AdminTaskRunSummary[],
+ }
+}
+
+export async function getMetadataTaskRunDetail(runId: string) {
+ return toAdminTaskRunDetail(await findTaskRunById(runId))
+}
+
+export async function createMetadataTaskRun(scope: AdminTaskScope) {
+ return withTaskLock(async () => {
+ const existingRun = await findActiveTaskRun('desc')
+ if (existingRun) throw new Error('Another metadata task is already active')
+
+ const totalCount = await countImagesForScope(scope)
+ if (totalCount < 1) throw new Error('No images matched the selected filters')
+
+ const now = new Date()
+ const rows = await db.$queryRaw`
+ INSERT INTO "public"."admin_task_runs" (
+ "id", "task_key", "status", "scope", "total_count", "processed_count",
+ "success_count", "skipped_count", "failed_count", "next_cursor", "recent_issues",
+ "last_error", "created_at", "updated_at"
+ ) VALUES (
+ ${createId()},
+ ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA},
+ 'queued',
+ ${jsonValue(scope)},
+ ${totalCount},
+ 0,
+ 0,
+ 0,
+ 0,
+ NULL,
+ ${jsonValue([])},
+ NULL,
+ ${now},
+ ${now}
+ )
+ ${taskRunReturning}
+ `
+
+ return toAdminTaskRunSummary(rows[0] ?? null)
+ })
+}
+
+export async function cancelMetadataTaskRun(runId: string) {
+ const record = await findTaskRunById(runId)
+ if (!record) return null
+
+ const status = record.status as AdminTaskStatus
+ if (!ACTIVE_TASK_STATUSES.includes(status)) return toAdminTaskRunSummary(record)
+
+ const now = new Date()
+ const leaseActive = hasActiveLease(record, now)
+
+ if (status === 'queued' || ((status === 'running' || status === 'cancelling') && !leaseActive)) {
+ const cancelledRecord = await finalizeTaskRunAsCancelled(runId, now)
+ abortTaskRunInProcess(runId, 'Task cancellation requested.')
+ return toAdminTaskRunSummary(cancelledRecord ?? record)
+ }
+
+ if (status === 'cancelling') {
+ abortTaskRunInProcess(runId, 'Task cancellation requested.')
+ return toAdminTaskRunSummary(record)
+ }
+
+ const rows = await db.$queryRaw`
+ UPDATE "public"."admin_task_runs"
+ SET
+ "status" = 'cancelling',
+ "updated_at" = ${now}
+ WHERE "id" = ${runId}
+ AND "status" = 'running'
+ ${taskRunReturning}
+ `
+
+ abortTaskRunInProcess(runId, 'Task cancellation requested.')
+
+ if (rows[0]) return toAdminTaskRunSummary(rows[0])
+ return toAdminTaskRunSummary(await findTaskRunById(runId))
+}
+
+export async function kickMetadataTaskRun(runId: string) {
+ const result = await withTaskLock(async () => {
+ const leasedRun = await leaseTaskRun(runId)
+ if (!leasedRun) return toAdminTaskRunSummary(await findTaskRunById(runId))
+
+ const controller = registerTaskAbortController(leasedRun.id)
+ const progress: TaskBatchProgress = {
+ processedCount: 0,
+ successCount: 0,
+ skippedCount: 0,
+ failedCount: 0,
+ nextCursor: leasedRun.nextCursor,
+ issues: [],
+ }
+
+ try {
+ const scope = normalizeMetadataTaskScope(leasedRun.scope)
+ const batch = await fetchImagesBatchForScope(scope, leasedRun.nextCursor)
+
+ if (batch.length === 0) {
+ return await finalizeTaskRunBatch(leasedRun.id, progress, 0, false)
+ }
+
+ let stoppedByCancellation = false
+
+ try {
+ for (const image of batch) {
+ await ensureTaskRunCanContinue(leasedRun.id, controller)
+
+ try {
+ const result = await refreshImageMetadata(image, controller.signal)
+ await ensureTaskRunCanContinue(leasedRun.id, controller)
+
+ progress.issues.push(...result.issues)
+
+ if (result.outcome === 'success') {
+ await updateImageMetadataFields(image.id, result.updates)
+ progress.successCount += 1
+ } else if (result.outcome === 'skipped') {
+ progress.skippedCount += 1
+ } else {
+ progress.failedCount += 1
+ }
+
+ progress.processedCount += 1
+ progress.nextCursor = image.id
+ } catch (error) {
+ if (isMetadataTaskCancelledError(error)) {
+ stoppedByCancellation = true
+ break
+ }
+
+ progress.failedCount += 1
+ progress.processedCount += 1
+ progress.nextCursor = image.id
+ progress.issues.push(createImageIssue(image, {
+ level: 'error',
+ stage: 'process-batch',
+ code: 'unexpected_error',
+ summary: 'Unexpected error while processing image metadata.',
+ detail: unknownErrorDetail(error),
+ }))
+ }
+ }
+ } catch (error) {
+ if (isMetadataTaskCancelledError(error)) {
+ stoppedByCancellation = true
+ } else {
+ return await finalizeFailedTaskRun(leasedRun.id, progress, error)
+ }
+ }
+
+ return await finalizeTaskRunBatch(leasedRun.id, progress, batch.length, stoppedByCancellation)
+ } finally {
+ clearTaskAbortController(leasedRun.id, controller)
+ }
+ })
+
+ return result ?? toAdminTaskRunSummary(await findTaskRunById(runId))
+}
+
+export async function tickMetadataTaskRuns() {
+ const runnableRecord = await findRunnableTaskRun('asc')
+ if (runnableRecord) {
+ return { activeRun: await kickMetadataTaskRun(runnableRecord.id) }
+ }
+
+ const activeRecord = await findActiveTaskRun('asc')
+ if (!activeRecord) return { activeRun: null }
+ return { activeRun: toAdminTaskRunSummary(activeRecord) }
+}
diff --git a/types/admin-tasks.ts b/types/admin-tasks.ts
new file mode 100644
index 00000000..66865f8d
--- /dev/null
+++ b/types/admin-tasks.ts
@@ -0,0 +1,96 @@
+export const ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA = 'refresh-image-metadata' as const
+
+export type AdminTaskKey = typeof ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA
+
+export const ADMIN_TASK_STATUSES = ['queued', 'running', 'cancelling', 'succeeded', 'failed', 'cancelled'] as const
+
+export type AdminTaskStatus = (typeof ADMIN_TASK_STATUSES)[number]
+
+export type AdminTaskScope = {
+ albumValue: string
+ showStatus: -1 | 0 | 1
+}
+
+export type AdminTaskIssueLevel = 'info' | 'warning' | 'error'
+
+export type AdminTaskStage =
+ | 'prepare'
+ | 'fetch'
+ | 'parse-exif'
+ | 'read-dimensions'
+ | 'persist'
+ | 'process-batch'
+ | 'unknown'
+
+export type AdminTaskIssue = {
+ imageId: string
+ imageTitle: string
+ taskKey: AdminTaskKey
+ level: AdminTaskIssueLevel
+ stage: AdminTaskStage
+ code: string
+ summary: string
+ detail: string | null
+ httpStatus: number | null
+ httpStatusText: string | null
+ at: string
+}
+
+export type AdminTaskError = {
+ message: string
+ detail: string | null
+ stage: AdminTaskStage
+ code: string
+ at: string
+}
+
+export type AdminTaskRunBase = {
+ id: string
+ taskKey: AdminTaskKey
+ status: AdminTaskStatus
+ scope: AdminTaskScope
+ totalCount: number
+ processedCount: number
+ successCount: number
+ skippedCount: number
+ failedCount: number
+ nextCursor: string | null
+ leaseExpiresAt: string | null
+ startedAt: string | null
+ finishedAt: string | null
+ createdAt: string
+ updatedAt: string | null
+}
+
+export type AdminTaskRunSummary = AdminTaskRunBase
+
+export type AdminTaskRunDetail = AdminTaskRunBase & {
+ recentIssues: AdminTaskIssue[]
+ lastError: AdminTaskError | null
+}
+
+export type AdminTaskRunsResponse = {
+ activeRun: AdminTaskRunSummary | null
+ recentRuns: AdminTaskRunSummary[]
+}
+
+export type AdminTaskPreviewCount = {
+ totalCount: number
+}
+
+export function normalizeMetadataTaskScope(scope: unknown): AdminTaskScope {
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
+ return {
+ albumValue: 'all',
+ showStatus: -1,
+ }
+ }
+
+ const rawScope = scope as Partial
+ const showStatus = Number(rawScope.showStatus)
+
+ return {
+ albumValue: typeof rawScope.albumValue === 'string' && rawScope.albumValue ? rawScope.albumValue : 'all',
+ showStatus: showStatus === 0 || showStatus === 1 ? showStatus : -1,
+ }
+}