From 8b247c00f2eed58f96506dab6fcc291e99c10db1 Mon Sep 17 00:00:00 2001 From: besscroft Date: Tue, 24 Mar 2026 21:12:56 +0800 Subject: [PATCH 1/2] feat(task): add image metadata maintenance task center - Add a backend task system that supports batch refreshing image EXIF and dimension metadata - Add the `/admin/tasks` page, including task creation, progress monitoring, and history - Implement task status management (queued, running, canceled, completed, etc.) - Add the `AnimatedIconTrigger` component to unify icon hover animations - Update the sidebar navigation to include a Task Center entry - Add multilingual support and update English, Chinese, and Japanese translation files - Add a database migration to create the `admin_task_runs` table for storing task execution records - Add Hono API routes to handle task-related requests - Fix inconsistent end-of-file newline characters --- app/admin/tasks/page.tsx | 5 + app/api/internal/tasks/tick/route.ts | 16 + components/admin/album/album-list.tsx | 61 +- components/admin/list/list-props.tsx | 156 ++-- components/admin/tasks/tasks-page.tsx | 827 ++++++++++++++++++ .../admin/upload/livephoto-file-upload.tsx | 64 +- .../admin/upload/multiple-file-upload.tsx | 29 +- .../admin/upload/simple-file-upload.tsx | 42 +- components/album/preview-image-exif.tsx | 61 +- components/album/preview-image.tsx | 203 +++-- components/album/tag-gallery.tsx | 24 +- components/icons/animated-trigger.tsx | 93 ++ components/icons/list-todo.tsx | 115 +++ components/icons/sparkles.tsx | 5 +- components/layout/admin/app-sidebar.tsx | 28 +- components/layout/admin/nav-main.tsx | 48 +- components/layout/admin/nav-projects.tsx | 37 +- components/layout/admin/nav-user.tsx | 123 ++- components/layout/command.tsx | 79 +- components/ui/sidebar.tsx | 37 +- hono/index.ts | 2 + hono/tasks.ts | 138 +++ messages/en.json | 56 +- messages/ja.json | 51 +- messages/zh-TW.json | 51 +- messages/zh.json | 51 +- package.json | 1 + pnpm-lock.yaml | 9 + .../migration.sql | 28 + prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 24 + server/lib/db.ts | 2 +- server/tasks/metadata-refresh.ts | 527 +++++++++++ types/admin-tasks.ts | 96 ++ 34 files changed, 2709 insertions(+), 382 deletions(-) create mode 100644 app/admin/tasks/page.tsx create mode 100644 app/api/internal/tasks/tick/route.ts create mode 100644 components/admin/tasks/tasks-page.tsx create mode 100644 components/icons/animated-trigger.tsx create mode 100644 components/icons/list-todo.tsx create mode 100644 hono/tasks.ts create mode 100644 prisma/migrations/20260323150247_add_admin_task_runs/migration.sql create mode 100644 server/tasks/metadata-refresh.ts create mode 100644 types/admin-tasks.ts 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/app/api/internal/tasks/tick/route.ts b/app/api/internal/tasks/tick/route.ts new file mode 100644 index 00000000..f860970f --- /dev/null +++ b/app/api/internal/tasks/tick/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' + +import { tickMetadataTaskRuns } from '~/server/tasks/service' + +export async function POST() { + try { + const data = await tickMetadataTaskRuns() + return NextResponse.json({ code: 200, message: 'Success', data }) + } catch (error) { + console.error('Task tick failed:', error) + return NextResponse.json({ code: 500, message: 'Task tick failed' }, { status: 500 }) + } +} + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' 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}
- - { - if (!value) { - setAlbum({} as AlbumType) - } - }}> - + + {({ iconRef, triggerProps }) => ( - + )} + + { + if (!value) { + setAlbum({} as AlbumType) + } + }}> + + {({ iconRef, triggerProps }) => ( + + + + )} + {t('Tips.reallyDelete')} @@ -164,4 +177,4 @@ export default function AlbumList(props : Readonly) { ))}
) -} \ 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 }) => ( + + + + )} +
- { - 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..2a11b54b --- /dev/null +++ b/hono/tasks.ts @@ -0,0 +1,138 @@ +import 'server-only' + +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception' + +import { + cancelMetadataTaskRun, + createMetadataTaskRun, + getMetadataTaskPreviewCount, + getMetadataTaskRunDetail, + kickMetadataTaskRun, + listMetadataTaskRuns, +} 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) + } +}) + +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/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, + } +} From c2a289ddceb5e76fadcd4a31c1fee38c867ac267 Mon Sep 17 00:00:00 2001 From: besscroft Date: Tue, 24 Mar 2026 21:34:04 +0800 Subject: [PATCH 2/2] refactor: migrate task tick API from Next.js routes to the Hono app Move the task tick endpoint from the Next.js API routes to the Hono app, and refactor the task service logic into a standalone module. --- app/api/internal/tasks/tick/route.ts | 16 - hono/tasks.ts | 11 + server/tasks/service.ts | 750 +++++++++++++++++++++++++++ 3 files changed, 761 insertions(+), 16 deletions(-) delete mode 100644 app/api/internal/tasks/tick/route.ts create mode 100644 server/tasks/service.ts diff --git a/app/api/internal/tasks/tick/route.ts b/app/api/internal/tasks/tick/route.ts deleted file mode 100644 index f860970f..00000000 --- a/app/api/internal/tasks/tick/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextResponse } from 'next/server' - -import { tickMetadataTaskRuns } from '~/server/tasks/service' - -export async function POST() { - try { - const data = await tickMetadataTaskRuns() - return NextResponse.json({ code: 200, message: 'Success', data }) - } catch (error) { - console.error('Task tick failed:', error) - return NextResponse.json({ code: 500, message: 'Task tick failed' }, { status: 500 }) - } -} - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' diff --git a/hono/tasks.ts b/hono/tasks.ts index 2a11b54b..9eec72c3 100644 --- a/hono/tasks.ts +++ b/hono/tasks.ts @@ -10,6 +10,7 @@ import { getMetadataTaskRunDetail, kickMetadataTaskRun, listMetadataTaskRuns, + tickMetadataTaskRuns, } from '~/server/tasks/service' import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, normalizeMetadataTaskScope } from '~/types/admin-tasks' @@ -135,4 +136,14 @@ app.post('/runs/:id/cancel', async (c) => { } }) +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/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) } +}