diff --git a/apps/server/public/openapi.json b/apps/server/public/openapi.json index 0e6e949e..1c62fbe0 100644 --- a/apps/server/public/openapi.json +++ b/apps/server/public/openapi.json @@ -754,6 +754,18 @@ } ] }, + "targetMediaModifiedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, "progress": { "anyOf": [ { @@ -837,6 +849,7 @@ "startedAt", "finishedAt", "targetMediaId", + "targetMediaModifiedAt", "progress", "artifact" ] diff --git a/apps/server/src/components/media/thumbnail-image.tsx b/apps/server/src/components/media/thumbnail-image.tsx index 9e48406e..b04ae6c2 100644 --- a/apps/server/src/components/media/thumbnail-image.tsx +++ b/apps/server/src/components/media/thumbnail-image.tsx @@ -24,7 +24,7 @@ type ThumbnailImageProps = { width?: number | null; }; -function buildUrl(args: BuildThumbnailUrlArgs): string { +export function buildThumbnailUrl(args: BuildThumbnailUrlArgs): string { const base = `/api/sources/${args.mediaSourceId}/thumbnail/${args.mediaId}`; const query = new URLSearchParams(); if (args.size) query.set("size", String(args.size)); @@ -36,7 +36,7 @@ function buildUrl(args: BuildThumbnailUrlArgs): string { export function ThumbnailImage(props: ThumbnailImageProps) { const source = createMemo(() => createHttpThumbnailSource({ - buildUrl, + buildUrl: buildThumbnailUrl, defaultSize: props.requestedSize ?? (props.sizes ? 512 : undefined), maxRetries: props.maxRetries, mediaId: props.media.id, diff --git a/apps/server/src/infrastructure/api/routers/jobs-router.ts b/apps/server/src/infrastructure/api/routers/jobs-router.ts index e172336a..451c5262 100644 --- a/apps/server/src/infrastructure/api/routers/jobs-router.ts +++ b/apps/server/src/infrastructure/api/routers/jobs-router.ts @@ -13,6 +13,7 @@ import { db } from "~/infrastructure/db"; import { jobs } from "~/infrastructure/db/schema"; import { RealtimeEventBus } from "~/infrastructure/events/realtime-event-bus"; import { JobRepository } from "~/infrastructure/repositories/job-repository"; +import { MediaRepository } from "~/infrastructure/repositories/media-repository"; const PublicJobFailureMessage = "Job failed"; @@ -50,7 +51,44 @@ function readProgress(payload: unknown) { }; } -export function toJobDto(job: Job) { +async function findTargetMediaModifiedAt( + job: Pick, +): Promise { + const targetMediaId = readTargetMediaId(job.payload); + if (!targetMediaId) return null; + + const media = await MediaRepository.findById(targetMediaId); + return media?.modifiedAt ?? null; +} + +async function findTargetMediaModifiedAtById( + jobs: ReadonlyArray>, +): Promise> { + const targetMediaIds = [ + ...new Set( + jobs.flatMap((job) => { + const targetMediaId = readTargetMediaId(job.payload); + return targetMediaId ? [targetMediaId] : []; + }), + ), + ]; + if (targetMediaIds.length === 0) return new Map(); + + const media = await MediaRepository.findByIds(targetMediaIds); + return new Map(media.map((item) => [item.id, item.modifiedAt])); +} + +function getTargetMediaModifiedAt( + job: Pick, + targetMediaModifiedAtById: ReadonlyMap, +): Date | null { + const targetMediaId = readTargetMediaId(job.payload); + return targetMediaId + ? (targetMediaModifiedAtById.get(targetMediaId) ?? null) + : null; +} + +export function toJobDto(job: Job, targetMediaModifiedAt: Date | null = null) { return { id: job.id, type: job.type, @@ -66,6 +104,7 @@ export function toJobDto(job: Job) { startedAt: job.startedAt ?? null, finishedAt: job.finishedAt ?? null, targetMediaId: readTargetMediaId(job.payload), + targetMediaModifiedAt, progress: readProgress(job.payload), artifact: job.status === "completed" && @@ -101,8 +140,12 @@ export const jobsRouter = os.router({ db.select({ total: count() }).from(jobs).where(where), ]); + const targetMediaModifiedAtById = await findTargetMediaModifiedAtById(rows); + return { - items: rows.map(toJobDto), + items: rows.map((job) => + toJobDto(job, getTargetMediaModifiedAt(job, targetMediaModifiedAtById)), + ), total: Number(totalRows[0]?.total ?? 0), }; }), @@ -112,7 +155,7 @@ export const jobsRouter = os.router({ if (!job) { throw new ORPCError("NOT_FOUND", { message: "Job not found" }); } - return toJobDto(job); + return toJobDto(job, await findTargetMediaModifiedAt(job)); }), downloadArtifact: os.downloadArtifact.handler(async ({ input }) => { @@ -162,7 +205,7 @@ export const jobsRouter = os.router({ jobId: requeued.id, message: "Job queued for retry", }); - return toJobDto(requeued); + return toJobDto(requeued, await findTargetMediaModifiedAt(requeued)); }), cancel: os.cancel.handler(async ({ input }) => { @@ -193,7 +236,7 @@ export const jobsRouter = os.router({ ? "Cancellation requested" : "Job cancelled", }); - return toJobDto(cancelled); + return toJobDto(cancelled, await findTargetMediaModifiedAt(cancelled)); }), events: os.events.handler(async function* ({ signal }) { diff --git a/apps/server/src/routes/v2/jobs.tsx b/apps/server/src/routes/v2/jobs.tsx index 5cd31a0d..24e4ac3e 100644 --- a/apps/server/src/routes/v2/jobs.tsx +++ b/apps/server/src/routes/v2/jobs.tsx @@ -11,6 +11,7 @@ import { toast } from "@solid-imager/ui/toast"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { createFileRoute } from "@tanstack/solid-router"; import { createSignal } from "solid-js"; +import { buildThumbnailUrl } from "~/components/media/thumbnail-image"; import { orpc } from "~/infrastructure/api-clients/orpc-client"; import { jobsQueryOptions } from "~/infrastructure/api-clients/queries"; @@ -57,6 +58,7 @@ function V2JobsRoute() { return ( jobsQuery.isFetching} jobs={() => jobsQuery.data?.items ?? []} onRefresh={async () => { diff --git a/apps/tauri/src/routes/jobs.tsx b/apps/tauri/src/routes/jobs.tsx index ddf5c9dc..70f4441d 100644 --- a/apps/tauri/src/routes/jobs.tsx +++ b/apps/tauri/src/routes/jobs.tsx @@ -16,6 +16,7 @@ import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { createFileRoute } from "@tanstack/solid-router"; import { createSignal } from "solid-js"; import { orpc } from "~/infrastructure/api-clients/orpc-client"; +import { buildThumbnailUrl } from "~/infrastructure/media/thumbnail-runtime"; import { jobsQueryOptions } from "~/queries"; const BULK_RETRY_CONCURRENCY = 8; @@ -87,6 +88,7 @@ function JobsRoute() { return (
jobsQuery.isFetching} jobs={() => jobsQuery.data?.items ?? []} onRefresh={async () => { diff --git a/packages/core/src/domain/jobs/schemas.ts b/packages/core/src/domain/jobs/schemas.ts index b02590c9..948bd928 100644 --- a/packages/core/src/domain/jobs/schemas.ts +++ b/packages/core/src/domain/jobs/schemas.ts @@ -46,6 +46,7 @@ export const jobDtoSchema = z.object({ startedAt: z.coerce.date().nullable(), finishedAt: z.coerce.date().nullable(), targetMediaId: z.string().uuid().nullable(), + targetMediaModifiedAt: z.coerce.date().nullable(), progress: jobProgressSchema, artifact: z .object({ diff --git a/packages/ui/src/query-options/jobs-query.test.ts b/packages/ui/src/query-options/jobs-query.test.ts index 6dd001d1..2bd2c009 100644 --- a/packages/ui/src/query-options/jobs-query.test.ts +++ b/packages/ui/src/query-options/jobs-query.test.ts @@ -19,6 +19,7 @@ const jobList: JobListResponse = { startedAt: new Date("2026-01-01T00:00:01.000Z"), status: "in_progress", targetMediaId: null, + targetMediaModifiedAt: null, type: "batch_tagging_parent", updatedAt: new Date("2026-01-01T00:00:02.000Z"), }, diff --git a/packages/ui/src/screens/v2-jobs-screen.tsx b/packages/ui/src/screens/v2-jobs-screen.tsx index b095ca13..1b5e8fc9 100644 --- a/packages/ui/src/screens/v2-jobs-screen.tsx +++ b/packages/ui/src/screens/v2-jobs-screen.tsx @@ -28,6 +28,12 @@ import { } from "../select"; import { LoadingRegion } from "../skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs"; +import { ThumbnailImage } from "../thumbnail-image"; +import { + type BuildThumbnailUrlArgs, + createHttpThumbnailSource, + type ThumbnailRequestSize, +} from "../thumbnail-source"; import { V2_CATEGORY_TABS_CLASS, V2CategoryLabel, @@ -80,6 +86,7 @@ export type V2JobsPagination = { }; export type V2JobsScreenProps = { + buildThumbnailUrl: (args: BuildThumbnailUrlArgs) => string; isRefreshing: Accessor; jobs: Accessor; onRefresh: () => void | Promise; @@ -179,10 +186,60 @@ function JobProgress(props: { progress: JobDto["progress"] }) { ); } +function JobThumbnail(props: { + alt: string; + buildUrl: (args: BuildThumbnailUrlArgs) => string; + class: string; + height: number; + job: JobDto; + requestedSize?: ThumbnailRequestSize; + width: number; +}) { + const source = createMemo(() => { + const targetMediaId = props.job.targetMediaId; + const mediaSourceId = props.job.mediaSourceId; + const targetMediaModifiedAt = props.job.targetMediaModifiedAt; + if (!targetMediaId || !mediaSourceId || !targetMediaModifiedAt) { + return undefined; + } + + return createHttpThumbnailSource({ + buildUrl: props.buildUrl, + defaultSize: props.requestedSize ?? 256, + mediaId: targetMediaId, + mediaSourceId, + modifiedAt: targetMediaModifiedAt, + }); + }); + + return ( + + ); +} + function JobsTable(props: { jobs: JobDto[]; onSelect: (job: JobDto) => void; onToggleSelect: (jobId: string) => void; + buildThumbnailUrl: (args: BuildThumbnailUrlArgs) => string; selectedJobId: string | null; selectedJobIds: ReadonlySet; }) { @@ -198,6 +255,9 @@ function JobsTable(props: { Select + + Target + Type @@ -247,6 +307,16 @@ function JobsTable(props: { + + +
+ } + when={job().targetMediaId && job().mediaSourceId} + > +
+ +

+ Target media +

+
+
Status
@@ -779,6 +876,7 @@ export function V2JobsScreen(props: V2JobsScreenProps) { /> setSelectedJobId(job.id)} onToggleSelect={toggleJob} @@ -797,6 +895,7 @@ export function V2JobsScreen(props: V2JobsScreenProps) { {(job) => (