Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,50 @@ describe('MediaDetailClient', () => {
render(<MediaDetailClient initial={asset} />);
expect(screen.getByTestId('media-detail-storage')).toMatchSnapshot();
});

it('hides the derivatives panel when no derivatives are present', () => {
render(<MediaDetailClient initial={asset} />);
expect(screen.queryByTestId('media-detail-derivatives')).not.toBeInTheDocument();
});

it('renders the HLS link when hls_url is set (issue #52)', () => {
const video: MediaAsset = {
...asset,
id: 'video-1',
mime_type: 'video/mp4',
filename: 'clip.mp4',
hls_url: 'https://cdn.example/hls/video-1/index.m3u8',
};
render(<MediaDetailClient initial={video} />);
const link = screen.getByTestId('hls-link');
expect(link).toHaveAttribute('href', 'https://cdn.example/hls/video-1/index.m3u8');
});

it('renders the extracted-text link when has_extracted_text is true (issue #60)', () => {
const pdf: MediaAsset = {
...asset,
id: 'pdf-1',
mime_type: 'application/pdf',
filename: 'doc.pdf',
has_extracted_text: true,
};
render(<MediaDetailClient initial={pdf} />);
const link = screen.getByTestId('extracted-text-link');
expect(link).toHaveAttribute('href', '/media/pdf-1/text');
});

it('renders the proxy source link for proxied assets (issue #187)', () => {
const proxied: MediaAsset = {
...asset,
id: 'proxy-1',
is_proxied: true,
source_url: 'https://oldsite.example/uploads/2024/03/photo.jpg',
};
render(<MediaDetailClient initial={proxied} />);
const link = screen.getByTestId('proxy-source-link');
expect(link).toHaveAttribute(
'href',
'https://oldsite.example/uploads/2024/03/photo.jpg',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ export function MediaDetailClient(props: MediaDetailClientProps): ReactElement {
onCopy={onCopyUrl}
/>

<DerivativesPanel asset={asset} />

<MetadataTable asset={asset} />

<div className="flex justify-end pt-2 border-t border-border-subtle">
Expand All @@ -300,6 +302,86 @@ export function MediaDetailClient(props: MediaDetailClientProps): ReactElement {
);
}

/**
* Derivatives panel — surfaces the asynchronously-produced artifacts
* the heavy-media worker writes back to the row. Currently:
*
* * HLS playlist URL (issue #52) — shown when `hls_url` is set;
* used by the public player to stream video over HLS instead of
* pulling the entire mp4.
* * Extracted text link (issue #60) — shown when
* `has_extracted_text` is true; opens the read-only extracted
* text view in a new tab so the operator can verify what was
* indexed.
* * Proxied / source URL (issue #187) — shown for migration rows
* in proxy mode; surfaces the origin URL with an "open" link so
* an operator can trace the row back to the source site.
*
* The panel renders nothing when none of the fields are set — most
* assets (images uploaded directly) have no derivatives and the
* panel stays out of the layout.
*/
function DerivativesPanel({ asset }: { asset: MediaAsset }): ReactElement | null {
const hasHls = Boolean(asset.hls_url);
const hasText = Boolean(asset.has_extracted_text);
const hasProxy = Boolean(asset.is_proxied && asset.source_url);
if (!hasHls && !hasText && !hasProxy) {
return null;
}
return (
<div
data-testid="media-detail-derivatives"
className="flex flex-col gap-2"
>
<span className="font-sans text-xs font-medium uppercase tracking-[0.12em] text-fg-subtle">
Derivatives
</span>
<div className="flex flex-col gap-2 rounded-lg border border-border bg-paper-2 px-4 py-3 shadow-xs">
{hasHls && (
<a
href={asset.hls_url}
target="_blank"
rel="noreferrer"
data-testid="hls-link"
className="inline-flex items-center gap-2 font-sans text-sm text-emerald-deep hover:text-emerald no-underline"
>
<Film width={14} height={14} aria-hidden="true" />
HLS playlist
<ExternalLink width={12} height={12} aria-hidden="true" />
</a>
)}
{hasText && (
<Link
href={`/media/${asset.id}/text`}
data-testid="extracted-text-link"
className="inline-flex items-center gap-2 font-sans text-sm text-emerald-deep hover:text-emerald no-underline"
>
<FileText width={14} height={14} aria-hidden="true" />
View extracted text
</Link>
)}
{hasProxy && (
<div className="flex flex-col gap-1">
<span className="font-sans text-xs text-fg-subtle">
Proxied from origin
</span>
<a
href={asset.source_url}
target="_blank"
rel="noreferrer"
data-testid="proxy-source-link"
className="font-mono text-xs text-ink-soft hover:text-ink truncate no-underline"
title={asset.source_url}
>
{asset.source_url}
</a>
</div>
)}
</div>
</div>
);
}

/**
* Storage URL panel — the operator's primary "give me the link"
* surface. Shows the URL in Geist Mono inside a paper-3 sunken pill
Expand Down
26 changes: 26 additions & 0 deletions apps/admin/src/app/(authenticated)/media/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,32 @@ export interface BulkResult {
op: string;
succeeded: number;
failed?: Record<string, string>;

/**
* HLS playlist URL for video assets — populated by the
* media.video.transcode worker (#52). The video player picks HLS
* over the raw mp4 source when this is set.
*/
hls_url?: string;

/**
* True when the media_text row exists for this asset. The detail
* page surfaces a "View extracted text" link based on this flag.
* Issue #60.
*/
has_extracted_text?: boolean;

/**
* True for assets registered in proxy mode by the migration
* importer (#187). The grid shows a "proxied" badge so operators
* can tell at a glance which assets live remotely.
*/
is_proxied?: boolean;

/**
* Origin URL for proxied assets. Empty for locally-stored assets.
*/
source_url?: string;
}

/**
Expand Down
67 changes: 53 additions & 14 deletions apps/api/internal/admin/media/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ type Deps struct {
// passes the taskspec-backed adapter.
Processor ProcessEnqueuer

// VideoProcessor enqueues media.video.transcode for video/* mime
// types. Optional — nil means no transcoding is fired (the upload
// row is still committed and queryable; the player will fall back
// to the raw video src). Issue #52.
VideoProcessor ProcessEnqueuer

// PDFProcessor enqueues media.pdf.process for application/pdf
// mime types. Optional — nil means no thumbnail/text extraction
// is fired. Issue #60.
PDFProcessor ProcessEnqueuer

// Logger receives structured log lines. nil falls back to
// slog.Default — useful for tests; production wiring should always
// pass a service logger.
Expand Down Expand Up @@ -72,13 +83,15 @@ func (d Deps) validate() error {
}

type handlers struct {
store Store
putter ObjectPutter
policy policy.Policy
processor ProcessEnqueuer
logger *slog.Logger
now func() time.Time
maxBytes int64
store Store
putter ObjectPutter
policy policy.Policy
processor ProcessEnqueuer
videoProcessor ProcessEnqueuer
pdfProcessor ProcessEnqueuer
logger *slog.Logger
now func() time.Time
maxBytes int64
}

// Mount wires the media routes onto mux under base (typically
Expand Down Expand Up @@ -106,13 +119,15 @@ func Mount(mux *http.ServeMux, base string, deps Deps) error {
}

h := &handlers{
store: deps.Store,
putter: deps.Putter,
policy: deps.Policy,
processor: deps.Processor,
logger: deps.Logger,
now: deps.Now,
maxBytes: maxBytes,
store: deps.Store,
putter: deps.Putter,
policy: deps.Policy,
processor: deps.Processor,
videoProcessor: deps.VideoProcessor,
pdfProcessor: deps.PDFProcessor,
logger: deps.Logger,
now: deps.Now,
maxBytes: maxBytes,
}
base = strings.TrimRight(base, "/")
mux.Handle("POST "+base, h.gate(policy.CapMediaUpload, h.upload))
Expand Down Expand Up @@ -295,6 +310,30 @@ func (h *handlers) upload(w http.ResponseWriter, r *http.Request, pr policy.Prin
}
}

// MIME-routed pipelines. Video uploads go through the HLS
// transcoder (#52); PDFs go through pdftoppm + pdftotext (#60).
// Both follow the same "log on enqueue failure, never fail the
// upload" policy as the image pipeline — the row is the user-
// visible artifact, derivatives are a follow-up.
if h.videoProcessor != nil && strings.HasPrefix(strings.ToLower(asset.MimeType), "video/") {
if err := h.videoProcessor.Enqueue(r.Context(), asset.ID, asset.StorageKey, asset.MimeType); err != nil {
h.logger.WarnContext(r.Context(), "admin/media: enqueue video transcode failed",
slog.String("asset_id", asset.ID),
slog.String("storage_key", asset.StorageKey),
slog.Any("err", err),
)
}
}
if h.pdfProcessor != nil && strings.EqualFold(strings.TrimSpace(asset.MimeType), "application/pdf") {
if err := h.pdfProcessor.Enqueue(r.Context(), asset.ID, asset.StorageKey, asset.MimeType); err != nil {
h.logger.WarnContext(r.Context(), "admin/media: enqueue pdf process failed",
slog.String("asset_id", asset.ID),
slog.String("storage_key", asset.StorageKey),
slog.Any("err", err),
)
}
}

router.WriteJSON(w, http.StatusCreated, asset)
}

Expand Down
25 changes: 25 additions & 0 deletions apps/api/internal/admin/media/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ type Asset struct {
// not yet completed; clients should treat absence as "fall back
// to the original via PublicURL".
Variants []Variant `json:"variants,omitempty"`

// HLSURL is the public URL of the HLS playlist produced by the
// media.video.transcode task (#52). Empty for non-video assets
// and for video assets whose transcode hasn't completed yet; the
// public player should fall back to PublicURL when this is
// empty.
HLSURL string `json:"hls_url,omitempty"`

// HasExtractedText is true when the media_text table has a row
// for this asset (#60). The detail page surfaces a "View
// extracted text" link based on this flag; the full payload
// lives behind a separate endpoint to keep the list response
// from ballooning on long documents.
HasExtractedText bool `json:"has_extracted_text,omitempty"`

// IsProxied is true when the row represents a remotely-hosted
// asset registered in proxy mode by the migration importer
// (#187). The image proxy serves the bytes via SourceURL; the
// admin grid surfaces a "proxied" badge so an operator can tell
// at a glance which assets are local vs remote.
IsProxied bool `json:"is_proxied,omitempty"`

// SourceURL is the origin URL for proxied assets. Empty for
// locally-stored assets.
SourceURL string `json:"source_url,omitempty"`
}

// Variant is one rendition produced by packages/go/media/imageproc.
Expand Down
Loading
Loading