diff --git a/app/admin/tasks/page.tsx b/app/admin/tasks/page.tsx new file mode 100644 index 00000000..ccf03ff9 --- /dev/null +++ b/app/admin/tasks/page.tsx @@ -0,0 +1,5 @@ +import TasksPage from '~/components/admin/tasks/tasks-page' + +export default function AdminTasksPage() { + return +} diff --git a/components/admin/album/album-list.tsx b/components/admin/album/album-list.tsx index 904d3c9b..b2e933b8 100644 --- a/components/admin/album/album-list.tsx +++ b/components/admin/album/album-list.tsx @@ -23,6 +23,7 @@ import { SquarePenIcon } from '~/components/icons/square-pen' import { DeleteIcon } from '~/components/icons/delete' import { useTranslations } from 'next-intl' import { Badge } from '~/components/ui/badge' +import { AnimatedIconTrigger, mergeAnimatedTriggerProps } from '~/components/icons/animated-trigger' export default function AlbumList(props : Readonly) { const { data, mutate } = useSwrHydrated(props) @@ -108,34 +109,46 @@ export default function AlbumList(props : Readonly) { {album.sort}
- - { - 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..9eec72c3 --- /dev/null +++ b/hono/tasks.ts @@ -0,0 +1,149 @@ +import 'server-only' + +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception' + +import { + cancelMetadataTaskRun, + createMetadataTaskRun, + getMetadataTaskPreviewCount, + getMetadataTaskRunDetail, + kickMetadataTaskRun, + listMetadataTaskRuns, + tickMetadataTaskRuns, +} from '~/server/tasks/service' +import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, normalizeMetadataTaskScope } from '~/types/admin-tasks' + +const app = new Hono() + +function ensureTaskKey(taskKey: unknown) { + if (taskKey !== ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA) { + throw new HTTPException(400, { message: 'Unsupported task key' }) + } + + return taskKey +} + +function getScopeFromQuery(query: Record) { + return normalizeMetadataTaskScope({ + albumValue: query.albumValue, + showStatus: query.showStatus, + }) +} + +function getScopeFromBody(body: Record | null) { + return normalizeMetadataTaskScope(body?.scope) +} + +function rethrowTaskError(error: unknown) { + if (error instanceof HTTPException) { + throw error + } + + const message = error instanceof Error ? error.message : 'Task request failed' + + if (message === 'Another metadata task is already active') { + throw new HTTPException(409, { message }) + } + + if (message === 'No images matched the selected filters') { + throw new HTTPException(400, { message }) + } + + throw new HTTPException(500, { message, cause: error }) +} + +app.get('/preview-count', async (c) => { + try { + const scope = getScopeFromQuery(c.req.query()) + const data = await getMetadataTaskPreviewCount(scope) + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.get('/runs', async (c) => { + try { + const data = await listMetadataTaskRuns() + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.get('/runs/:id', async (c) => { + try { + const data = await getMetadataTaskRunDetail(c.req.param('id')) + + if (!data) { + throw new HTTPException(404, { message: 'Task run not found' }) + } + + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/runs', async (c) => { + const body = await c.req.json>().catch(() => null) + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new HTTPException(400, { message: 'Invalid task request body' }) + } + + try { + ensureTaskKey(body.taskKey) + const scope = getScopeFromBody(body) + const data = await createMetadataTaskRun(scope) + + if (!data) { + throw new HTTPException(409, { message: 'Task system is busy, please retry shortly' }) + } + + return c.json({ code: 200, message: 'Success', data }, 201) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/runs/:id/kick', async (c) => { + try { + const data = await kickMetadataTaskRun(c.req.param('id')) + + if (!data) { + throw new HTTPException(404, { message: 'Task run not found' }) + } + + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/runs/:id/cancel', async (c) => { + try { + const data = await cancelMetadataTaskRun(c.req.param('id')) + + if (!data) { + throw new HTTPException(404, { message: 'Task run not found' }) + } + + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/tick', async (c) => { + try { + const data = await tickMetadataTaskRuns() + return c.json({ code: 200, message: 'Success', data }) + } catch (error) { + console.error('Task tick failed:', error) + rethrowTaskError(error) + } +}) + +export default app diff --git a/messages/en.json b/messages/en.json index 041e0b3d..90cbe42e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -40,7 +40,8 @@ "storages": "Storage Configuration", "authenticator": "Two-Factor Authentication", "passkey": "Passkey", - "daily": "Daily Homepage" + "daily": "Daily Homepage", + "tasks": "Tasks" }, "Passkey": { "title": "Passkey Management", @@ -210,7 +211,7 @@ }, "Upload": { "simple": "Single File Upload", - "livephoto": "LivePhoto Upload", + "livephoto": "LivePhoto Upload", "multiple": "Multiple Files Upload", "manualUpload": "Manual Upload", "uploadTips1": "Click to upload file or drag file here", @@ -224,7 +225,7 @@ "videoUrl": "LivePhoto Video URL", "width": "Image Width px", "height": "Image Height px", - "lon": "Longitude", + "lon": "Longitude", "lat": "Latitude", "inputLon": "Enter Longitude", "inputLat": "Enter Latitude", @@ -490,5 +491,54 @@ "totalParticipating": "Participating Albums", "saveSuccess": "Saved successfully!", "saveFailed": "Save failed!" + }, + "Tasks": { + "cancelling": "Cancelling", + "cancel": "Cancel task", + "progressLabel": "Progress", + "matchedCount": "Matched images", + "processedLabel": "Processed", + "successLabel": "Updated", + "skippedLabel": "Skipped", + "failedLabel": "Failed", + "startedAtLabel": "Started at", + "notAvailable": "N/A", + "previewLabel": "Preview count", + "currentSelection": "Current selection", + "starting": "Starting", + "start": "Start maintenance", + "noMatchHint": "No images match the current filters, so the task cannot be created.", + "runningHint": "One image metadata task is already active. A new run can start only after it fully stops.", + "albumsLoading": "Loading albums...", + "loading": "Loading...", + "historyEyebrow": "History", + "historyRecentLimit": "Latest {count} runs", + "noHistory": "No task history yet.", + "statusQueued": "Queued", + "statusRunning": "Running", + "statusCancelling": "Cancelling", + "statusSucceeded": "Succeeded", + "statusFailed": "Failed", + "statusCancelled": "Cancelled", + "startSuccess": "Task created successfully.", + "startFailed": "Failed to create task.", + "cancelSuccess": "Cancellation requested.", + "cancelFailed": "Failed to cancel task.", + "kickFailed": "Failed to advance task.", + "noIssues": "No logs were recorded for this run.", + "lastErrorTitle": "Last error", + "issuesTitle": "Recent logs", + "finishedAtLabel": "Finished at", + "issueInfo": "Info", + "issueWarning": "Warning", + "issueError": "Error", + "timelineTitle": "Timeline", + "detailView": "View details", + "detailDescription": "Inspect the scope, progress, outcome counts, timeline, and recent logs from this run.", + "detailEyebrow": "Run details", + "unfinished": "Still running", + "countsTitle": "Outcome counts" } } + + diff --git a/messages/ja.json b/messages/ja.json index 32dd54e0..06fd2f8f 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -40,7 +40,8 @@ "storages": "ストレージ設定", "authenticator": "二要素認証", "passkey": "Passkey", - "daily": "デイリーホームページ" + "daily": "デイリーホームページ", + "tasks": "タスクセンター" }, "Passkey": { "title": "Passkey 管理", @@ -490,5 +491,53 @@ "totalParticipating": "参加アルバム数", "saveSuccess": "保存成功!", "saveFailed": "保存失敗!" + }, + "Tasks": { + "cancelling": "キャンセル中", + "cancel": "タスクをキャンセル", + "progressLabel": "進行状況", + "matchedCount": "対象画像", + "processedLabel": "処理済み", + "successLabel": "更新済み", + "skippedLabel": "スキップ", + "failedLabel": "失敗", + "startedAtLabel": "開始時刻", + "notAvailable": "なし", + "previewLabel": "対象件数", + "currentSelection": "現在の選択", + "starting": "開始中", + "start": "メンテナンスを開始", + "noMatchHint": "現在の条件に一致する画像がないため、タスクを作成できません。", + "runningHint": "画像メタデータタスクがすでにアクティブです。完全に停止するまで新しいタスクは開始できません。", + "albumsLoading": "アルバムを読み込み中...", + "loading": "読み込み中...", + "historyEyebrow": "履歴", + "historyRecentLimit": "最新 {count} 件", + "noHistory": "タスク履歴はまだありません。", + "statusQueued": "待機中", + "statusRunning": "実行中", + "statusCancelling": "キャンセル中", + "statusSucceeded": "完了", + "statusFailed": "失敗", + "statusCancelled": "キャンセル済み", + "startSuccess": "タスクを作成しました。", + "startFailed": "タスクの作成に失敗しました。", + "cancelSuccess": "キャンセル要求を送信しました。", + "cancelFailed": "タスクのキャンセルに失敗しました。", + "kickFailed": "タスクの進行に失敗しました。", + "noIssues": "この実行ではログは記録されませんでした。", + "lastErrorTitle": "最後のエラー", + "issuesTitle": "最近のログ", + "finishedAtLabel": "終了時刻", + "issueInfo": "情報", + "issueWarning": "警告", + "issueError": "エラー", + "timelineTitle": "タイムライン", + "detailView": "詳細を見る", + "detailDescription": "この実行の対象範囲、進行状況、結果、タイムライン、最近のログを確認します。", + "detailEyebrow": "実行詳細", + "unfinished": "継続中", + "countsTitle": "結果" } } + diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 26fbe192..eebc5bd4 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -40,7 +40,8 @@ "storages": "儲存配置", "authenticator": "雙因素驗證", "passkey": "Passkey", - "daily": "Daily 首頁" + "daily": "Daily 首頁", + "tasks": "任務中心" }, "Passkey": { "title": "Passkey 管理", @@ -490,5 +491,53 @@ "totalParticipating": "參與相簿數", "saveSuccess": "儲存成功!", "saveFailed": "儲存失敗!" + }, + "Tasks": { + "cancelling": "正在取消", + "cancel": "取消任務", + "progressLabel": "處理進度", + "matchedCount": "命中圖片", + "processedLabel": "已處理", + "successLabel": "已更新", + "skippedLabel": "已跳過", + "failedLabel": "失敗", + "startedAtLabel": "開始時間", + "notAvailable": "暫無", + "previewLabel": "命中預覽", + "currentSelection": "當前選擇", + "starting": "正在啟動", + "start": "開始維護", + "noMatchHint": "當前篩選條件下沒有命中圖片,無法建立任務。", + "runningHint": "已有圖片中繼資料維護任務處於活動狀態,必須等待它完全停止後才能開始新任務。", + "albumsLoading": "相簿載入中...", + "loading": "載入中...", + "historyEyebrow": "歷史記錄", + "historyRecentLimit": "最近 {count} 筆", + "noHistory": "暫無任務記錄。", + "statusQueued": "排隊中", + "statusRunning": "執行中", + "statusCancelling": "取消中", + "statusSucceeded": "已完成", + "statusFailed": "已失敗", + "statusCancelled": "已取消", + "startSuccess": "任務建立成功。", + "startFailed": "任務建立失敗。", + "cancelSuccess": "已發送取消請求。", + "cancelFailed": "取消任務失敗。", + "kickFailed": "推進任務失敗。", + "noIssues": "本次任務沒有記錄到日誌。", + "lastErrorTitle": "最後一次錯誤", + "issuesTitle": "最近日誌", + "finishedAtLabel": "結束時間", + "issueInfo": "資訊", + "issueWarning": "警告", + "issueError": "錯誤", + "timelineTitle": "時間線", + "detailView": "查看詳情", + "detailDescription": "查看本次任務的範圍、進度、結果統計、時間線和最近日誌。", + "detailEyebrow": "任務詳情", + "unfinished": "未結束", + "countsTitle": "結果統計" } } + diff --git a/messages/zh.json b/messages/zh.json index 4205f6f2..02478068 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -40,7 +40,8 @@ "storages": "存储配置", "authenticator": "双因素验证", "passkey": "Passkey", - "daily": "Daily 首页" + "daily": "Daily 首页", + "tasks": "任务中心" }, "Passkey": { "title": "Passkey 管理", @@ -490,5 +491,53 @@ "totalParticipating": "参与相册数", "saveSuccess": "保存成功!", "saveFailed": "保存失败!" + }, + "Tasks": { + "cancelling": "正在取消", + "cancel": "取消任务", + "progressLabel": "处理进度", + "matchedCount": "命中图片", + "processedLabel": "已处理", + "successLabel": "已更新", + "skippedLabel": "已跳过", + "failedLabel": "失败", + "startedAtLabel": "开始时间", + "notAvailable": "暂无", + "previewLabel": "命中预览", + "currentSelection": "当前选择", + "starting": "正在启动", + "start": "开始维护", + "noMatchHint": "当前筛选条件下没有命中图片,无法创建任务。", + "runningHint": "已有图片元数据维护任务处于活动状态,必须等待它彻底停止后才能开始新任务。", + "albumsLoading": "相册加载中...", + "loading": "加载中...", + "historyEyebrow": "历史记录", + "historyRecentLimit": "最近 {count} 条", + "noHistory": "暂无任务记录。", + "statusQueued": "排队中", + "statusRunning": "执行中", + "statusCancelling": "取消中", + "statusSucceeded": "已完成", + "statusFailed": "已失败", + "statusCancelled": "已取消", + "startSuccess": "任务创建成功。", + "startFailed": "任务创建失败。", + "cancelSuccess": "已发送取消请求。", + "cancelFailed": "取消任务失败。", + "kickFailed": "推进任务失败。", + "noIssues": "本次任务没有记录到日志。", + "lastErrorTitle": "最后一次错误", + "issuesTitle": "最近日志", + "finishedAtLabel": "结束时间", + "issueInfo": "信息", + "issueWarning": "警告", + "issueError": "错误", + "timelineTitle": "时间线", + "detailView": "查看详情", + "detailDescription": "查看本次任务的范围、进度、结果统计、时间线和最近日志。", + "detailEyebrow": "任务详情", + "unfinished": "未结束", + "countsTitle": "结果统计" } } + diff --git a/package.json b/package.json index 91d1de0d..81fc1344 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "embla-carousel-react": "8.6.0", "emblor": "1.4.8", "exifreader": "4.36.2", + "@xmldom/xmldom": "^0.8.11", "heic-to": "1.4.2", "heic2any": "0.0.4", "hono": "4.12.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4406b31d..21b3d561 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: '@radix-ui/react-tooltip': specifier: 1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@xmldom/xmldom': + specifier: ^0.8.11 + version: 0.8.11 better-auth: specifier: 1.5.0 version: 1.5.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(drizzle-orm@0.45.1(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(kysely@0.28.11)(prisma@6.19.2(typescript@5.9.3)))(mongodb@7.1.0)(next@16.2.1(@babel/core@7.27.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(prisma@6.19.2(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -4032,6 +4035,10 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + '@xmldom/xmldom@0.9.5': resolution: {integrity: sha512-6g1EwSs8cr8JhP1iBxzyVAWM6BIDvx9Y3FZRIQiMDzgG43Pxi8YkWOZ0nQj2NHgNzgXDZbJewFx/n+YAvMZrfg==} engines: {node: '>=14.6'} @@ -11822,6 +11829,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.9.5': optional: true diff --git a/prisma/migrations/20260323150247_add_admin_task_runs/migration.sql b/prisma/migrations/20260323150247_add_admin_task_runs/migration.sql new file mode 100644 index 00000000..de03723b --- /dev/null +++ b/prisma/migrations/20260323150247_add_admin_task_runs/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "admin_task_runs" ( + "id" VARCHAR(50) NOT NULL, + "task_key" VARCHAR(100) NOT NULL, + "status" VARCHAR(50) NOT NULL DEFAULT 'queued', + "scope" JSON NOT NULL, + "total_count" INTEGER NOT NULL DEFAULT 0, + "processed_count" INTEGER NOT NULL DEFAULT 0, + "success_count" INTEGER NOT NULL DEFAULT 0, + "skipped_count" INTEGER NOT NULL DEFAULT 0, + "failed_count" INTEGER NOT NULL DEFAULT 0, + "next_cursor" VARCHAR(50), + "recent_issues" JSON NOT NULL DEFAULT '[]', + "last_error" JSON, + "lease_expires_at" TIMESTAMP, + "started_at" TIMESTAMP, + "finished_at" TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP, + + CONSTRAINT "admin_task_runs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "admin_task_runs_task_key_status_idx" ON "admin_task_runs"("task_key", "status"); + +-- CreateIndex +CREATE INDEX "admin_task_runs_status_idx" ON "admin_task_runs"("status"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 648c57fd..044d57cd 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aa46efb3..cd3ee957 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -173,3 +173,27 @@ model Passkey { @@map("passkey") } + +model AdminTaskRun { + id String @id @default(cuid()) @db.VarChar(50) + taskKey String @map("task_key") @db.VarChar(100) + status String @default("queued") @db.VarChar(50) + scope Json @db.Json + totalCount Int @default(0) @map("total_count") + processedCount Int @default(0) @map("processed_count") + successCount Int @default(0) @map("success_count") + skippedCount Int @default(0) @map("skipped_count") + failedCount Int @default(0) @map("failed_count") + nextCursor String? @map("next_cursor") @db.VarChar(50) + recentIssues Json @default("[]") @map("recent_issues") @db.Json + lastError Json? @map("last_error") @db.Json + leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamp + startedAt DateTime? @map("started_at") @db.Timestamp + finishedAt DateTime? @map("finished_at") @db.Timestamp + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp + + @@index([taskKey, status]) + @@index([status]) + @@map("admin_task_runs") +} diff --git a/server/lib/db.ts b/server/lib/db.ts index 239f41e9..bbe706bd 100644 --- a/server/lib/db.ts +++ b/server/lib/db.ts @@ -12,4 +12,4 @@ const prisma = globalThis.prisma || prismaClientSingleton() export const db = prisma -globalThis.prisma = prisma \ No newline at end of file +globalThis.prisma = prisma diff --git a/server/tasks/metadata-refresh.ts b/server/tasks/metadata-refresh.ts new file mode 100644 index 00000000..517abdb7 --- /dev/null +++ b/server/tasks/metadata-refresh.ts @@ -0,0 +1,527 @@ +import 'server-only' + +import dayjs from 'dayjs' +import ExifReader from 'exifreader' +import sharp from 'sharp' +import { DOMParser } from '@xmldom/xmldom' + +import type { ExifType } from '~/types' +import type { AdminTaskIssue, AdminTaskStage } from '~/types/admin-tasks' +import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA } from '~/types/admin-tasks' + +const exifDomParser = new DOMParser() +const FETCH_TIMEOUT_MS = 20_000 + +export class MetadataTaskCancelledError extends Error { + constructor(message = 'Task cancellation requested.') { + super(message) + this.name = 'MetadataTaskCancelledError' + } +} + +const EMPTY_EXIF: ExifType = { + make: '', + model: '', + bits: '', + data_time: '', + exposure_time: '', + f_number: '', + exposure_program: '', + iso_speed_rating: '', + focal_length: '', + lens_specification: '', + lens_model: '', + exposure_mode: '', + cfa_pattern: '', + color_space: '', + white_balance: '', +} + +type ExifTag = { + value?: unknown + description?: string +} + +type ExifTags = Record + +type IssueInput = { + level: AdminTaskIssue['level'] + stage: AdminTaskStage + code: string + summary: string + detail?: string | null + httpStatus?: number | null + httpStatusText?: string | null +} + +type CoordinateUpdateResult = { + updates: Pick + issue?: AdminTaskIssue +} + +export type MetadataRefreshImage = { + id: string + image_name: string | null + title: string | null + url: string | null + exif: unknown + width: number + height: number + lat: string | null + lon: string | null +} + +export type MetadataRefreshUpdate = { + exif?: ExifType + width?: number + height?: number + lat?: string + lon?: string +} + +export type MetadataRefreshResult = { + outcome: 'success' | 'skipped' | 'failed' + updates: MetadataRefreshUpdate + issues: AdminTaskIssue[] +} + +function cleanString(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function cancellationReasonMessage(reason: unknown) { + if (reason instanceof Error && reason.message.trim()) return reason.message + if (typeof reason === 'string' && reason.trim()) return reason.trim() + return 'Task cancellation requested.' +} + +export function createMetadataTaskCancelledError(reason?: unknown) { + return reason instanceof MetadataTaskCancelledError + ? reason + : new MetadataTaskCancelledError(cancellationReasonMessage(reason)) +} + +export function isMetadataTaskCancelledError(error: unknown): error is MetadataTaskCancelledError { + return error instanceof MetadataTaskCancelledError + || (error instanceof Error && error.name === 'MetadataTaskCancelledError') +} + +export function throwIfMetadataTaskCancelled(signal?: AbortSignal) { + if (!signal?.aborted) return + throw createMetadataTaskCancelledError(signal.reason) +} + +function nullableString(value: unknown) { + const normalized = cleanString(value) + return normalized || null +} + +function pickImageTitle(image: Pick) { + return cleanString(image.title) || cleanString(image.image_name) || image.id +} + +function toErrorDetail(error: unknown) { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return null +} + +function timeoutDetail(error: unknown) { + const fallback = `Fetch timed out after ${FETCH_TIMEOUT_MS / 1000} seconds.` + const detail = toErrorDetail(error) + if (!detail) return fallback + + const normalized = detail.toLowerCase().replace(/\s+/g, ' ').trim() + if ( + normalized === 'the operation was aborted due to timeout' + || normalized === 'this operation was aborted' + || normalized === 'signal is aborted without reason' + ) { + return fallback + } + + return detail +} + +function normalizeExif(input: Partial | null | undefined): ExifType | null { + const exif: ExifType = { + make: cleanString(input?.make), + model: cleanString(input?.model), + bits: cleanString(input?.bits), + data_time: cleanString(input?.data_time), + exposure_time: cleanString(input?.exposure_time), + f_number: cleanString(input?.f_number), + exposure_program: cleanString(input?.exposure_program), + iso_speed_rating: cleanString(input?.iso_speed_rating), + focal_length: cleanString(input?.focal_length), + lens_specification: cleanString(input?.lens_specification), + lens_model: cleanString(input?.lens_model), + exposure_mode: cleanString(input?.exposure_mode), + cfa_pattern: cleanString(input?.cfa_pattern), + color_space: cleanString(input?.color_space), + white_balance: cleanString(input?.white_balance), + } + + if (exif.data_time && !dayjs(exif.data_time).isValid()) { + exif.data_time = '' + } + + return Object.values(exif).some(Boolean) ? exif : null +} + +function normalizeStoredExif(input: unknown) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + return normalizeExif(input as Partial) +} + +function areExifEqual(left: ExifType | null, right: ExifType | null) { + return JSON.stringify(left ?? EMPTY_EXIF) === JSON.stringify(right ?? EMPTY_EXIF) +} + +function rationalToNumber(value: [number, number]) { + const [numerator, denominator] = value + if (!denominator) { + return null + } + + return numerator / denominator +} + +function normalizeCoordinate(value: number) { + const rounded = Number(value.toFixed(6)) + return rounded.toString() +} + +function gpsTripletToDecimal( + coordinates: [[number, number], [number, number], [number, number]] | undefined, + reference: unknown, + positiveRef: string, + negativeRef: string, +) { + if (!coordinates || !Array.isArray(coordinates) || coordinates.length !== 3) { + return null + } + + const [degrees, minutes, seconds] = coordinates + const degreeValue = rationalToNumber(degrees) + const minuteValue = rationalToNumber(minutes) + const secondValue = rationalToNumber(seconds) + + if ( + degreeValue === null + || minuteValue === null + || secondValue === null + ) { + return null + } + + let decimal = degreeValue + minuteValue / 60 + secondValue / 3600 + const ref = Array.isArray(reference) ? reference.join('') : cleanString(reference) + + if (ref === negativeRef) { + decimal *= -1 + } else if (ref !== positiveRef && ref !== negativeRef) { + return null + } + + return decimal +} + +function createIssue( + image: MetadataRefreshImage, + { level, stage, code, summary, detail = null, httpStatus = null, httpStatusText = null }: IssueInput, +): AdminTaskIssue { + return { + imageId: image.id, + imageTitle: pickImageTitle(image), + taskKey: ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, + level, + stage, + code, + summary, + detail, + httpStatus, + httpStatusText: nullableString(httpStatusText), + at: new Date().toISOString(), + } +} + +function createSkippedReasonIssue(image: MetadataRefreshImage): AdminTaskIssue { + return createIssue(image, { + level: 'info', + stage: 'persist', + code: 'metadata_already_current', + summary: 'Metadata is already up to date.', + detail: 'The refreshed metadata matched the stored values, so no changes were required.', + }) +} + +function getCoordinateUpdates(tags: ExifTags | null, image: MetadataRefreshImage): CoordinateUpdateResult { + if (!tags) { + return { updates: {} } + } + + const updates: Pick = {} + const detailParts: string[] = [] + + const hasLatitude = Boolean(tags.GPSLatitude || tags.GPSLatitudeRef) + const latitude = gpsTripletToDecimal( + tags.GPSLatitude?.value as [[number, number], [number, number], [number, number]] | undefined, + tags.GPSLatitudeRef?.value, + 'N', + 'S', + ) + if (typeof latitude === 'number') { + if (latitude >= -90 && latitude <= 90) { + updates.lat = normalizeCoordinate(latitude) + } else { + detailParts.push(`Latitude ${latitude} is out of range.`) + } + } else if (hasLatitude) { + detailParts.push('Latitude could not be parsed from EXIF GPS tags.') + } + + const hasLongitude = Boolean(tags.GPSLongitude || tags.GPSLongitudeRef) + const longitude = gpsTripletToDecimal( + tags.GPSLongitude?.value as [[number, number], [number, number], [number, number]] | undefined, + tags.GPSLongitudeRef?.value, + 'E', + 'W', + ) + if (typeof longitude === 'number') { + if (longitude >= -180 && longitude <= 180) { + updates.lon = normalizeCoordinate(longitude) + } else { + detailParts.push(`Longitude ${longitude} is out of range.`) + } + } else if (hasLongitude) { + detailParts.push('Longitude could not be parsed from EXIF GPS tags.') + } + + if (detailParts.length > 0) { + return { + updates, + issue: createIssue(image, { + level: 'warning', + stage: 'parse-exif', + code: 'invalid_gps_coordinates', + summary: 'Ignored invalid GPS coordinates.', + detail: detailParts.join(' '), + }), + } + } + + return { updates } +} + +function buildNormalizedExifFromTags(tags: ExifTags | null) { + if (!tags) { + return null + } + + return normalizeExif({ + make: tags.Make?.description, + model: tags.Model?.description, + bits: tags['Bits Per Sample']?.description, + data_time: tags.DateTimeOriginal?.description || tags.DateTime?.description, + exposure_time: tags.ExposureTime?.description, + f_number: tags.FNumber?.description, + exposure_program: tags.ExposureProgram?.description, + iso_speed_rating: tags.ISOSpeedRatings?.description, + focal_length: tags.FocalLength?.description, + lens_specification: tags.LensSpecification?.description, + lens_model: tags.LensModel?.description, + exposure_mode: tags.ExposureMode?.description, + cfa_pattern: tags.CFAPattern?.description, + color_space: tags.ColorSpace?.description, + white_balance: tags.WhiteBalance?.description, + }) +} + +function isTimeoutError(error: unknown) { + if (!(error instanceof Error)) return false + return error.name === 'TimeoutError' || error.name === 'AbortError' || /timeout/i.test(error.message) +} + +export async function refreshImageMetadata(image: MetadataRefreshImage, signal?: AbortSignal): Promise { + throwIfMetadataTaskCancelled(signal) + + if (!image.url) { + return { + outcome: 'failed', + updates: {}, + issues: [createIssue(image, { + level: 'error', + stage: 'prepare', + code: 'missing_source_url', + summary: 'Missing source image URL.', + detail: 'The image record does not include an original image URL.', + })], + } + } + + let buffer: Buffer + const fetchSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS) + + try { + throwIfMetadataTaskCancelled(signal) + const response = await fetch(image.url, { + signal: fetchSignal, + cache: 'no-store', + }) + + if (!response.ok) { + return { + outcome: 'failed', + updates: {}, + issues: [createIssue(image, { + level: 'error', + stage: 'fetch', + code: 'http_error', + summary: 'Failed to fetch the original image.', + detail: `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`, + httpStatus: response.status, + httpStatusText: response.statusText, + })], + } + } + + buffer = Buffer.from(await response.arrayBuffer()) + throwIfMetadataTaskCancelled(signal) + } catch (error) { + if (signal?.aborted || isMetadataTaskCancelledError(error)) { + throw createMetadataTaskCancelledError(signal?.reason ?? error) + } + + const timeout = isTimeoutError(error) + return { + outcome: 'failed', + updates: {}, + issues: [createIssue(image, { + level: 'error', + stage: 'fetch', + code: timeout ? 'timeout' : 'fetch_error', + summary: timeout ? 'Timed out while fetching the original image.' : 'Failed to fetch the original image.', + detail: timeout ? timeoutDetail(error) : (toErrorDetail(error) || 'Unknown fetch error.'), + })], + } + } + + const issues: AdminTaskIssue[] = [] + let tags: ExifTags | null = null + let exifCandidate: ExifType | null = null + + try { + throwIfMetadataTaskCancelled(signal) + tags = await ExifReader.load(buffer, { + async: true, + domParser: exifDomParser, + }) as ExifTags + throwIfMetadataTaskCancelled(signal) + exifCandidate = buildNormalizedExifFromTags(tags) + } catch (error) { + if (signal?.aborted || isMetadataTaskCancelledError(error)) { + throw createMetadataTaskCancelledError(signal?.reason ?? error) + } + + tags = null + issues.push(createIssue(image, { + level: 'warning', + stage: 'parse-exif', + code: 'exif_read_failed', + summary: 'Failed to parse EXIF metadata.', + detail: toErrorDetail(error) || 'ExifReader could not parse the image metadata payload.', + })) + } + + let widthCandidate: number | undefined + let heightCandidate: number | undefined + + try { + throwIfMetadataTaskCancelled(signal) + const metadata = await sharp(buffer).metadata() + throwIfMetadataTaskCancelled(signal) + + if (metadata.width && metadata.width > 0) { + widthCandidate = metadata.width + } + if (metadata.height && metadata.height > 0) { + heightCandidate = metadata.height + } + } catch (error) { + if (signal?.aborted || isMetadataTaskCancelledError(error)) { + throw createMetadataTaskCancelledError(signal?.reason ?? error) + } + + widthCandidate = undefined + heightCandidate = undefined + issues.push(createIssue(image, { + level: 'warning', + stage: 'read-dimensions', + code: 'read_dimensions_failed', + summary: 'Failed to read image dimensions.', + detail: toErrorDetail(error) || 'Sharp could not read image dimensions from the downloaded file.', + })) + } + + throwIfMetadataTaskCancelled(signal) + const updates: MetadataRefreshUpdate = {} + const coordinateResult = getCoordinateUpdates(tags, image) + const storedExif = normalizeStoredExif(image.exif) + + if (coordinateResult.issue) { + issues.push(coordinateResult.issue) + } + + if (exifCandidate && !areExifEqual(storedExif, exifCandidate)) { + updates.exif = exifCandidate + } + if (typeof widthCandidate === 'number' && widthCandidate !== image.width) { + updates.width = widthCandidate + } + if (typeof heightCandidate === 'number' && heightCandidate !== image.height) { + updates.height = heightCandidate + } + if (coordinateResult.updates.lat && coordinateResult.updates.lat !== cleanString(image.lat)) { + updates.lat = coordinateResult.updates.lat + } + if (coordinateResult.updates.lon && coordinateResult.updates.lon !== cleanString(image.lon)) { + updates.lon = coordinateResult.updates.lon + } + + if (Object.keys(updates).length > 0) { + return { + outcome: 'success', + updates, + issues, + } + } + + const hasAnyParsedMetadata = + Boolean(exifCandidate) || + typeof widthCandidate === 'number' || + typeof heightCandidate === 'number' || + Boolean(coordinateResult.updates.lat) || + Boolean(coordinateResult.updates.lon) + + if (!hasAnyParsedMetadata) { + issues.push(createIssue(image, { + level: 'warning', + stage: 'prepare', + code: 'no_valid_metadata', + summary: 'No valid metadata could be extracted.', + detail: 'The refreshed image did not yield usable EXIF, dimensions, or GPS coordinates.', + })) + } else { + issues.push(createSkippedReasonIssue(image)) + } + + return { + outcome: 'skipped', + updates: {}, + issues, + } +} diff --git a/server/tasks/service.ts b/server/tasks/service.ts new file mode 100644 index 00000000..3cf59e03 --- /dev/null +++ b/server/tasks/service.ts @@ -0,0 +1,750 @@ +import 'server-only' + +import { createId } from '@paralleldrive/cuid2' +import { Prisma } from '@prisma/client' + +import { buildShowFilter } from '~/server/db/query/helpers' +import { db } from '~/server/lib/db' +import { + createMetadataTaskCancelledError, + isMetadataTaskCancelledError, + refreshImageMetadata, + throwIfMetadataTaskCancelled, + type MetadataRefreshImage, + type MetadataRefreshUpdate, +} from '~/server/tasks/metadata-refresh' +import type { + AdminTaskError, + AdminTaskIssue, + AdminTaskRunDetail, + AdminTaskRunSummary, + AdminTaskRunsResponse, + AdminTaskScope, + AdminTaskStage, + AdminTaskStatus, +} from '~/types/admin-tasks' +import { ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, normalizeMetadataTaskScope } from '~/types/admin-tasks' + +const METADATA_TASK_BATCH_SIZE = 10 +const METADATA_TASK_LOCK_ID = 42011 +const METADATA_TASK_LEASE_MS = 2 * 60 * 1000 +const ACTIVE_TASK_STATUSES: AdminTaskStatus[] = ['queued', 'running', 'cancelling'] +const RUNNABLE_TASK_STATUSES: AdminTaskStatus[] = ['queued', 'running'] +const RECENT_HISTORY_LIMIT = 10 +const VALID_TASK_STAGES = new Set(['prepare', 'fetch', 'parse-exif', 'read-dimensions', 'persist', 'process-batch', 'unknown']) + +declare const globalThis: { + metadataTaskAbortControllers?: Map; +} & typeof global + +const taskAbortControllers = globalThis.metadataTaskAbortControllers || new Map() +globalThis.metadataTaskAbortControllers = taskAbortControllers + +type TaskRunRecord = { + id: string + taskKey: string + status: string + scope: unknown + totalCount: number + processedCount: number + successCount: number + skippedCount: number + failedCount: number + nextCursor: string | null + recentIssues: unknown + lastError: unknown + leaseExpiresAt: Date | null + startedAt: Date | null + finishedAt: Date | null + createdAt: Date + updatedAt: Date | null +} + +type IssueInput = { + level: AdminTaskIssue['level'] + stage: AdminTaskStage + code: string + summary: string + detail?: string | null + httpStatus?: number | null + httpStatusText?: string | null +} + +type TaskBatchProgress = { + processedCount: number + successCount: number + skippedCount: number + failedCount: number + nextCursor: string | null + issues: AdminTaskIssue[] +} + +const taskRunSelect = Prisma.sql` + SELECT + "id", + "task_key" AS "taskKey", + "status", + "scope", + "total_count" AS "totalCount", + "processed_count" AS "processedCount", + "success_count" AS "successCount", + "skipped_count" AS "skippedCount", + "failed_count" AS "failedCount", + "next_cursor" AS "nextCursor", + "recent_issues" AS "recentIssues", + "last_error" AS "lastError", + "lease_expires_at" AS "leaseExpiresAt", + "started_at" AS "startedAt", + "finished_at" AS "finishedAt", + "created_at" AS "createdAt", + "updated_at" AS "updatedAt" + FROM "public"."admin_task_runs" +` + +const taskRunReturning = Prisma.sql` + RETURNING + "id", + "task_key" AS "taskKey", + "status", + "scope", + "total_count" AS "totalCount", + "processed_count" AS "processedCount", + "success_count" AS "successCount", + "skipped_count" AS "skippedCount", + "failed_count" AS "failedCount", + "next_cursor" AS "nextCursor", + "recent_issues" AS "recentIssues", + "last_error" AS "lastError", + "lease_expires_at" AS "leaseExpiresAt", + "started_at" AS "startedAt", + "finished_at" AS "finishedAt", + "created_at" AS "createdAt", + "updated_at" AS "updatedAt" +` + +function jsonValue(value: unknown) { + return Prisma.sql`${JSON.stringify(value)}::json` +} + +function cleanString(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function nullableString(value: unknown) { + const normalized = cleanString(value) + return normalized || null +} + +function normalizeTaskStage(value: unknown): AdminTaskStage { + const normalized = cleanString(value) + return VALID_TASK_STAGES.has(normalized as AdminTaskStage) ? normalized as AdminTaskStage : 'unknown' +} + +function normalizeNullableNumber(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + return value +} + +function serializeDate(value: Date | null | undefined) { + return value ? value.toISOString() : null +} + +function fallbackIssueTime(record: Pick) { + return serializeDate(record.updatedAt) ?? record.createdAt.toISOString() +} + +function normalizeTaskIssueArray(value: unknown, fallbackAt: string): AdminTaskIssue[] { + if (!Array.isArray(value)) return [] + + return value.flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return [] + + const rawIssue = item as Record + const imageId = cleanString(rawIssue.imageId) + const summary = cleanString(rawIssue.summary) + if (!imageId || !summary) return [] + + return [{ + imageId, + imageTitle: cleanString(rawIssue.imageTitle) || imageId, + taskKey: ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, + level: cleanString(rawIssue.level) === 'error' + ? 'error' + : cleanString(rawIssue.level) === 'info' + ? 'info' + : 'warning', + stage: normalizeTaskStage(rawIssue.stage), + code: cleanString(rawIssue.code) || 'unknown_error', + summary, + detail: nullableString(rawIssue.detail), + httpStatus: normalizeNullableNumber(rawIssue.httpStatus), + httpStatusText: nullableString(rawIssue.httpStatusText), + at: cleanString(rawIssue.at) || fallbackAt, + } satisfies AdminTaskIssue] + }) +} + +function normalizeTaskError(value: unknown, fallbackAt: string): AdminTaskError | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + + const rawError = value as Record + const message = cleanString(rawError.message) + if (!message) return null + + return { + message, + detail: nullableString(rawError.detail), + stage: normalizeTaskStage(rawError.stage), + code: cleanString(rawError.code) || 'unknown_error', + at: cleanString(rawError.at) || fallbackAt, + } +} + +function toTaskRunBase(record: TaskRunRecord) { + return { + id: record.id, + taskKey: record.taskKey as AdminTaskRunSummary['taskKey'], + status: record.status as AdminTaskStatus, + scope: normalizeMetadataTaskScope(record.scope), + totalCount: record.totalCount, + processedCount: record.processedCount, + successCount: record.successCount, + skippedCount: record.skippedCount, + failedCount: record.failedCount, + nextCursor: record.nextCursor, + leaseExpiresAt: serializeDate(record.leaseExpiresAt), + startedAt: serializeDate(record.startedAt), + finishedAt: serializeDate(record.finishedAt), + createdAt: record.createdAt.toISOString(), + updatedAt: serializeDate(record.updatedAt), + } +} + +function toAdminTaskRunSummary(record: TaskRunRecord | null): AdminTaskRunSummary | null { + if (!record) return null + return toTaskRunBase(record) +} + +function toAdminTaskRunDetail(record: TaskRunRecord | null): AdminTaskRunDetail | null { + if (!record) return null + + const fallbackAt = fallbackIssueTime(record) + return { + ...toTaskRunBase(record), + recentIssues: normalizeTaskIssueArray(record.recentIssues, fallbackAt), + lastError: normalizeTaskError(record.lastError, fallbackAt), + } +} + +function createTaskError(message: string, options?: { detail?: string | null; stage?: AdminTaskStage; code?: string }): AdminTaskError { + return { + message, + detail: options?.detail ?? null, + stage: options?.stage ?? 'process-batch', + code: options?.code ?? 'batch_failed', + at: new Date().toISOString(), + } +} + +function mergeRecentIssues(existing: unknown, incoming: AdminTaskIssue[], fallbackAt: string) { + if (incoming.length === 0) return normalizeTaskIssueArray(existing, fallbackAt) + return [...normalizeTaskIssueArray(existing, fallbackAt), ...incoming].slice(-20) +} + +function buildTaskScopeQuery(scope: AdminTaskScope) { + if (scope.albumValue !== 'all') { + return { + from: Prisma.sql` + FROM "public"."images" AS image + INNER JOIN "public"."images_albums_relation" AS relation ON image.id = relation."imageId" + INNER JOIN "public"."albums" AS album ON relation.album_value = album.album_value + `, + where: Prisma.sql` + WHERE image.del = 0 AND album.del = 0 AND album.album_value = ${scope.albumValue} + ${buildShowFilter(scope.showStatus)} + `, + } + } + + return { + from: Prisma.sql`FROM "public"."images" AS image`, + where: Prisma.sql`WHERE image.del = 0 ${buildShowFilter(scope.showStatus)}`, + } +} + +function imageTitle(image: Pick) { + return cleanString(image.title) || cleanString(image.image_name) || image.id +} + +function createImageIssue(image: MetadataRefreshImage, input: IssueInput): AdminTaskIssue { + return { + imageId: image.id, + imageTitle: imageTitle(image), + taskKey: ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA, + level: input.level, + stage: input.stage, + code: input.code, + summary: input.summary, + detail: input.detail ?? null, + httpStatus: input.httpStatus ?? null, + httpStatusText: nullableString(input.httpStatusText), + at: new Date().toISOString(), + } +} + +function unknownErrorDetail(error: unknown) { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return 'Unknown error.' +} + +function hasActiveLease(record: Pick, now = new Date()) { + return Boolean(record.leaseExpiresAt && record.leaseExpiresAt.getTime() > now.getTime()) +} + +function registerTaskAbortController(runId: string) { + const controller = new AbortController() + taskAbortControllers.set(runId, controller) + return controller +} + +function clearTaskAbortController(runId: string, controller?: AbortController) { + const current = taskAbortControllers.get(runId) + if (!current) return + if (controller && current !== controller) return + taskAbortControllers.delete(runId) +} + +function abortTaskController(controller: AbortController, reason?: unknown) { + if (controller.signal.aborted) return + controller.abort(createMetadataTaskCancelledError(reason)) +} + +function abortTaskRunInProcess(runId: string, reason?: unknown) { + const controller = taskAbortControllers.get(runId) + if (!controller) return false + abortTaskController(controller, reason) + return true +} + +async function countImagesForScope(scope: AdminTaskScope) { + const query = buildTaskScopeQuery(scope) + const result = await db.$queryRaw>` + SELECT COUNT(DISTINCT image.id) AS total + ${query.from} + ${query.where} + ` + return Number(result[0]?.total ?? 0) +} + +async function fetchImagesBatchForScope(scope: AdminTaskScope, nextCursor: string | null) { + const query = buildTaskScopeQuery(scope) + const cursorFilter = nextCursor ? Prisma.sql`AND image.id > ${nextCursor}` : Prisma.empty + + return db.$queryRaw` + SELECT DISTINCT ON (image.id) + image.id, + image.image_name, + image.title, + image.url, + image.exif, + image.width, + image.height, + image.lat, + image.lon + ${query.from} + ${query.where} + ${cursorFilter} + ORDER BY image.id ASC + LIMIT ${METADATA_TASK_BATCH_SIZE} + ` +} + +async function updateImageMetadataFields(imageId: string, updates: MetadataRefreshUpdate) { + const data: Prisma.ImagesUpdateInput = { updatedAt: new Date() } + if (updates.exif) data.exif = updates.exif + if (typeof updates.width === 'number') data.width = updates.width + if (typeof updates.height === 'number') data.height = updates.height + if (typeof updates.lat === 'string') data.lat = updates.lat + if (typeof updates.lon === 'string') data.lon = updates.lon + + return db.images.update({ where: { id: imageId }, data }) +} + +async function withTaskLock(callback: () => Promise) { + const lockResult = await db.$queryRaw>` + SELECT pg_try_advisory_lock(${METADATA_TASK_LOCK_ID}) + ` + + if (!lockResult[0]?.pg_try_advisory_lock) return null + + try { + return await callback() + } finally { + await db.$executeRaw`SELECT pg_advisory_unlock(${METADATA_TASK_LOCK_ID})` + } +} + +async function findTaskRunById(runId: string) { + const rows = await db.$queryRaw` + ${taskRunSelect} + WHERE "id" = ${runId} + LIMIT 1 + ` + return rows[0] ?? null +} + +async function findActiveTaskRun(order: 'asc' | 'desc' = 'desc') { + const orderSql = Prisma.raw(order.toUpperCase()) + const rows = await db.$queryRaw` + ${taskRunSelect} + WHERE "task_key" = ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA} + AND "status" IN (${Prisma.join(ACTIVE_TASK_STATUSES)}) + ORDER BY "created_at" ${orderSql} + LIMIT 1 + ` + return rows[0] ?? null +} + +async function findRunnableTaskRun(order: 'asc' | 'desc' = 'desc') { + const orderSql = Prisma.raw(order.toUpperCase()) + const rows = await db.$queryRaw` + ${taskRunSelect} + WHERE "task_key" = ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA} + AND "status" IN (${Prisma.join(RUNNABLE_TASK_STATUSES)}) + ORDER BY "created_at" ${orderSql} + LIMIT 1 + ` + return rows[0] ?? null +} + +async function listRecentTaskRunRecords(limit: number, excludeRunId: string | null) { + const excludeSql = excludeRunId ? Prisma.sql`AND "id" <> ${excludeRunId}` : Prisma.empty + + return db.$queryRaw` + ${taskRunSelect} + WHERE "task_key" = ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA} + ${excludeSql} + ORDER BY "created_at" DESC + LIMIT ${limit} + ` +} + +async function leaseTaskRun(runId: string) { + const now = new Date() + const leaseExpiresAt = new Date(now.getTime() + METADATA_TASK_LEASE_MS) + + const rows = await db.$queryRaw` + UPDATE "public"."admin_task_runs" + SET + "status" = 'running', + "started_at" = COALESCE("started_at", ${now}), + "lease_expires_at" = ${leaseExpiresAt}, + "last_error" = NULL, + "updated_at" = ${now} + WHERE "id" = ${runId} + AND "status" IN (${Prisma.join(RUNNABLE_TASK_STATUSES)}) + AND ("lease_expires_at" IS NULL OR "lease_expires_at" <= ${now}) + ${taskRunReturning} + ` + + return rows[0] ?? null +} + +async function finalizeTaskRunAsCancelled(runId: string, now = new Date()) { + const rows = await db.$queryRaw` + UPDATE "public"."admin_task_runs" + SET + "status" = 'cancelled', + "finished_at" = COALESCE("finished_at", ${now}), + "lease_expires_at" = NULL, + "updated_at" = ${now} + WHERE "id" = ${runId} + ${taskRunReturning} + ` + + return rows[0] ?? null +} + +async function ensureTaskRunCanContinue(runId: string, controller: AbortController) { + throwIfMetadataTaskCancelled(controller.signal) + + const record = await findTaskRunById(runId) + if (!record) { + abortTaskController(controller, 'Task run no longer exists.') + throw createMetadataTaskCancelledError('Task run no longer exists.') + } + + if (record.status === 'cancelling' || record.status === 'cancelled') { + abortTaskController(controller, 'Task cancellation requested.') + throw createMetadataTaskCancelledError('Task cancellation requested.') + } + + throwIfMetadataTaskCancelled(controller.signal) + return record +} + +async function finalizeFailedTaskRun(runId: string, progress: TaskBatchProgress, error: unknown) { + const currentRecord = await findTaskRunById(runId) + if (!currentRecord) return null + + const now = new Date() + const nextCursor = progress.processedCount > 0 ? progress.nextCursor : currentRecord.nextCursor + const batchError = jsonValue(createTaskError('Failed to process task batch.', { + detail: unknownErrorDetail(error), + stage: 'process-batch', + code: 'batch_failed', + })) + + const rows = await db.$queryRaw` + UPDATE "public"."admin_task_runs" + SET + "processed_count" = "processed_count" + ${progress.processedCount}, + "success_count" = "success_count" + ${progress.successCount}, + "skipped_count" = "skipped_count" + ${progress.skippedCount}, + "failed_count" = "failed_count" + ${progress.failedCount}, + "next_cursor" = ${nextCursor}, + "recent_issues" = ${jsonValue(mergeRecentIssues(currentRecord.recentIssues, progress.issues, fallbackIssueTime(currentRecord)))}, + "lease_expires_at" = NULL, + "status" = CASE + WHEN "status" IN ('cancelling', 'cancelled') THEN 'cancelled' + ELSE 'failed' + END, + "finished_at" = COALESCE("finished_at", ${now}), + "last_error" = CASE + WHEN "status" IN ('cancelling', 'cancelled') THEN "last_error" + ELSE ${batchError} + END, + "updated_at" = ${now} + WHERE "id" = ${runId} + ${taskRunReturning} + ` + + return toAdminTaskRunSummary(rows[0] ?? null) +} + +async function finalizeTaskRunBatch(runId: string, progress: TaskBatchProgress, batchLength: number, stoppedByCancellation: boolean) { + const currentRecord = await findTaskRunById(runId) + if (!currentRecord) return null + + const finalProcessedCount = currentRecord.processedCount + progress.processedCount + const nextCursor = progress.processedCount > 0 ? progress.nextCursor : currentRecord.nextCursor + const isCompleted = !stoppedByCancellation && ( + finalProcessedCount >= currentRecord.totalCount + || (batchLength < METADATA_TASK_BATCH_SIZE && progress.processedCount === batchLength) + ) + const now = new Date() + + const rows = await db.$queryRaw` + UPDATE "public"."admin_task_runs" + SET + "processed_count" = "processed_count" + ${progress.processedCount}, + "success_count" = "success_count" + ${progress.successCount}, + "skipped_count" = "skipped_count" + ${progress.skippedCount}, + "failed_count" = "failed_count" + ${progress.failedCount}, + "next_cursor" = ${nextCursor}, + "recent_issues" = ${jsonValue(mergeRecentIssues(currentRecord.recentIssues, progress.issues, fallbackIssueTime(currentRecord)))}, + "lease_expires_at" = NULL, + "status" = CASE + WHEN "status" IN ('cancelling', 'cancelled') THEN 'cancelled' + WHEN ${isCompleted} THEN 'succeeded' + ELSE 'running' + END, + "finished_at" = CASE + WHEN "status" IN ('cancelling', 'cancelled') OR ${isCompleted} THEN COALESCE("finished_at", ${now}) + ELSE NULL + END, + "updated_at" = ${now} + WHERE "id" = ${runId} + ${taskRunReturning} + ` + + return toAdminTaskRunSummary(rows[0] ?? null) +} + +export async function getMetadataTaskPreviewCount(scope: AdminTaskScope) { + return { totalCount: await countImagesForScope(scope) } +} + +export async function listMetadataTaskRuns(): Promise { + const activeRecord = await findActiveTaskRun('desc') + const activeRun = toAdminTaskRunSummary(activeRecord) + const historyRecords = await listRecentTaskRunRecords(RECENT_HISTORY_LIMIT, activeRecord?.id ?? null) + + return { + activeRun, + recentRuns: historyRecords + .map((record) => toAdminTaskRunSummary(record)) + .filter(Boolean) as AdminTaskRunSummary[], + } +} + +export async function getMetadataTaskRunDetail(runId: string) { + return toAdminTaskRunDetail(await findTaskRunById(runId)) +} + +export async function createMetadataTaskRun(scope: AdminTaskScope) { + return withTaskLock(async () => { + const existingRun = await findActiveTaskRun('desc') + if (existingRun) throw new Error('Another metadata task is already active') + + const totalCount = await countImagesForScope(scope) + if (totalCount < 1) throw new Error('No images matched the selected filters') + + const now = new Date() + const rows = await db.$queryRaw` + INSERT INTO "public"."admin_task_runs" ( + "id", "task_key", "status", "scope", "total_count", "processed_count", + "success_count", "skipped_count", "failed_count", "next_cursor", "recent_issues", + "last_error", "created_at", "updated_at" + ) VALUES ( + ${createId()}, + ${ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA}, + 'queued', + ${jsonValue(scope)}, + ${totalCount}, + 0, + 0, + 0, + 0, + NULL, + ${jsonValue([])}, + NULL, + ${now}, + ${now} + ) + ${taskRunReturning} + ` + + return toAdminTaskRunSummary(rows[0] ?? null) + }) +} + +export async function cancelMetadataTaskRun(runId: string) { + const record = await findTaskRunById(runId) + if (!record) return null + + const status = record.status as AdminTaskStatus + if (!ACTIVE_TASK_STATUSES.includes(status)) return toAdminTaskRunSummary(record) + + const now = new Date() + const leaseActive = hasActiveLease(record, now) + + if (status === 'queued' || ((status === 'running' || status === 'cancelling') && !leaseActive)) { + const cancelledRecord = await finalizeTaskRunAsCancelled(runId, now) + abortTaskRunInProcess(runId, 'Task cancellation requested.') + return toAdminTaskRunSummary(cancelledRecord ?? record) + } + + if (status === 'cancelling') { + abortTaskRunInProcess(runId, 'Task cancellation requested.') + return toAdminTaskRunSummary(record) + } + + const rows = await db.$queryRaw` + UPDATE "public"."admin_task_runs" + SET + "status" = 'cancelling', + "updated_at" = ${now} + WHERE "id" = ${runId} + AND "status" = 'running' + ${taskRunReturning} + ` + + abortTaskRunInProcess(runId, 'Task cancellation requested.') + + if (rows[0]) return toAdminTaskRunSummary(rows[0]) + return toAdminTaskRunSummary(await findTaskRunById(runId)) +} + +export async function kickMetadataTaskRun(runId: string) { + const result = await withTaskLock(async () => { + const leasedRun = await leaseTaskRun(runId) + if (!leasedRun) return toAdminTaskRunSummary(await findTaskRunById(runId)) + + const controller = registerTaskAbortController(leasedRun.id) + const progress: TaskBatchProgress = { + processedCount: 0, + successCount: 0, + skippedCount: 0, + failedCount: 0, + nextCursor: leasedRun.nextCursor, + issues: [], + } + + try { + const scope = normalizeMetadataTaskScope(leasedRun.scope) + const batch = await fetchImagesBatchForScope(scope, leasedRun.nextCursor) + + if (batch.length === 0) { + return await finalizeTaskRunBatch(leasedRun.id, progress, 0, false) + } + + let stoppedByCancellation = false + + try { + for (const image of batch) { + await ensureTaskRunCanContinue(leasedRun.id, controller) + + try { + const result = await refreshImageMetadata(image, controller.signal) + await ensureTaskRunCanContinue(leasedRun.id, controller) + + progress.issues.push(...result.issues) + + if (result.outcome === 'success') { + await updateImageMetadataFields(image.id, result.updates) + progress.successCount += 1 + } else if (result.outcome === 'skipped') { + progress.skippedCount += 1 + } else { + progress.failedCount += 1 + } + + progress.processedCount += 1 + progress.nextCursor = image.id + } catch (error) { + if (isMetadataTaskCancelledError(error)) { + stoppedByCancellation = true + break + } + + progress.failedCount += 1 + progress.processedCount += 1 + progress.nextCursor = image.id + progress.issues.push(createImageIssue(image, { + level: 'error', + stage: 'process-batch', + code: 'unexpected_error', + summary: 'Unexpected error while processing image metadata.', + detail: unknownErrorDetail(error), + })) + } + } + } catch (error) { + if (isMetadataTaskCancelledError(error)) { + stoppedByCancellation = true + } else { + return await finalizeFailedTaskRun(leasedRun.id, progress, error) + } + } + + return await finalizeTaskRunBatch(leasedRun.id, progress, batch.length, stoppedByCancellation) + } finally { + clearTaskAbortController(leasedRun.id, controller) + } + }) + + return result ?? toAdminTaskRunSummary(await findTaskRunById(runId)) +} + +export async function tickMetadataTaskRuns() { + const runnableRecord = await findRunnableTaskRun('asc') + if (runnableRecord) { + return { activeRun: await kickMetadataTaskRun(runnableRecord.id) } + } + + const activeRecord = await findActiveTaskRun('asc') + if (!activeRecord) return { activeRun: null } + return { activeRun: toAdminTaskRunSummary(activeRecord) } +} diff --git a/types/admin-tasks.ts b/types/admin-tasks.ts new file mode 100644 index 00000000..66865f8d --- /dev/null +++ b/types/admin-tasks.ts @@ -0,0 +1,96 @@ +export const ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA = 'refresh-image-metadata' as const + +export type AdminTaskKey = typeof ADMIN_TASK_KEY_REFRESH_IMAGE_METADATA + +export const ADMIN_TASK_STATUSES = ['queued', 'running', 'cancelling', 'succeeded', 'failed', 'cancelled'] as const + +export type AdminTaskStatus = (typeof ADMIN_TASK_STATUSES)[number] + +export type AdminTaskScope = { + albumValue: string + showStatus: -1 | 0 | 1 +} + +export type AdminTaskIssueLevel = 'info' | 'warning' | 'error' + +export type AdminTaskStage = + | 'prepare' + | 'fetch' + | 'parse-exif' + | 'read-dimensions' + | 'persist' + | 'process-batch' + | 'unknown' + +export type AdminTaskIssue = { + imageId: string + imageTitle: string + taskKey: AdminTaskKey + level: AdminTaskIssueLevel + stage: AdminTaskStage + code: string + summary: string + detail: string | null + httpStatus: number | null + httpStatusText: string | null + at: string +} + +export type AdminTaskError = { + message: string + detail: string | null + stage: AdminTaskStage + code: string + at: string +} + +export type AdminTaskRunBase = { + id: string + taskKey: AdminTaskKey + status: AdminTaskStatus + scope: AdminTaskScope + totalCount: number + processedCount: number + successCount: number + skippedCount: number + failedCount: number + nextCursor: string | null + leaseExpiresAt: string | null + startedAt: string | null + finishedAt: string | null + createdAt: string + updatedAt: string | null +} + +export type AdminTaskRunSummary = AdminTaskRunBase + +export type AdminTaskRunDetail = AdminTaskRunBase & { + recentIssues: AdminTaskIssue[] + lastError: AdminTaskError | null +} + +export type AdminTaskRunsResponse = { + activeRun: AdminTaskRunSummary | null + recentRuns: AdminTaskRunSummary[] +} + +export type AdminTaskPreviewCount = { + totalCount: number +} + +export function normalizeMetadataTaskScope(scope: unknown): AdminTaskScope { + if (!scope || typeof scope !== 'object' || Array.isArray(scope)) { + return { + albumValue: 'all', + showStatus: -1, + } + } + + const rawScope = scope as Partial + const showStatus = Number(rawScope.showStatus) + + return { + albumValue: typeof rawScope.albumValue === 'string' && rawScope.albumValue ? rawScope.albumValue : 'all', + showStatus: showStatus === 0 || showStatus === 1 ? showStatus : -1, + } +}