From 122d7aa3323d010c2d77c48d3cbd553b6557e5d9 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:49:42 -0400 Subject: [PATCH] add series grouping, cover upload, and multi-zip download to games library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Series grouping: a top-level folder with no .zip but game subfolders is now treated as a GameSeries. Clicking a series drills into it with a breadcrumb; a game-count badge distinguishes series cards from game cards. Series fall back to the first game's cover when no series-level cover exists. - Cover upload: new POST /api/game-cover endpoint writes cover.jpg or widecover.jpg directly into the game/series folder (re-encoded via sharp). A kebab menu on GameDetailModal opens an Edit Images panel showing previews and upload/replace buttons for both cover and wide cover. - Multi-zip download: Game.zipFiles replaces zipPath and includes all .zip files in the folder. A single zip shows the existing download button; multiple zips render a split button โ€” primary action downloads the first file, a dropdown arrow lists all files by name. Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/game-cover/route.ts | 99 +++++ src/components/games/GameDetailModal.tsx | 444 ++++++++++++++++++++--- src/components/games/GamesView.tsx | 222 ++++++++---- src/lib/games.ts | 124 +++++-- src/types/index.ts | 10 +- 5 files changed, 739 insertions(+), 160 deletions(-) create mode 100644 src/app/api/game-cover/route.ts diff --git a/src/app/api/game-cover/route.ts b/src/app/api/game-cover/route.ts new file mode 100644 index 0000000..b2fcaf3 --- /dev/null +++ b/src/app/api/game-cover/route.ts @@ -0,0 +1,99 @@ +import path from 'path' +import fs from 'fs' +import sharp from 'sharp' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' + +const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB + +type CoverType = 'cover' | 'widecover' + +function isCoverType(s: string | null): s is CoverType { + return s === 'cover' || s === 'widecover' +} + +export async function POST(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const itemId = searchParams.get('itemId') + const coverType = searchParams.get('coverType') + + if (!libraryId || !itemId) { + return NextResponse.json({ error: 'Missing libraryId or itemId' }, { status: 400 }) + } + if (!isCoverType(coverType)) { + return NextResponse.json({ error: 'coverType must be "cover" or "widecover"' }, { status: 400 }) + } + + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + if (library.type !== 'games') { + return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 }) + } + + const libraryRoot = resolveLibraryRoot(library) + const folderPath = decodeURIComponent(itemId) + + let resolvedDir: string + try { + resolvedDir = resolveAndJail(libraryRoot, folderPath) + } catch { + return NextResponse.json({ error: 'Invalid item path' }, { status: 400 }) + } + + if (!fs.existsSync(resolvedDir)) { + return NextResponse.json({ error: 'Game folder not found' }, { status: 404 }) + } + + let formData: FormData + try { + formData = await request.formData() + } catch { + return NextResponse.json({ error: 'Invalid form data' }, { status: 400 }) + } + + const file = formData.get('cover') + if (!(file instanceof File)) { + return NextResponse.json({ error: 'cover file is required' }, { status: 400 }) + } + + if (file.size > MAX_COVER_BYTES) { + return NextResponse.json({ error: 'File too large. Maximum size is 10 MB.' }, { status: 400 }) + } + + const rawBuffer = Buffer.from(await file.arrayBuffer()) + + let processedBuffer: Buffer + try { + processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer() + } catch { + return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 }) + } + + const destFilename = `${coverType}.jpg` + const destPath = path.join(resolvedDir, destFilename) + + // Remove any existing file with the same base name but a different extension + const basePattern = new RegExp(`^${coverType}\\.`, 'i') + try { + for (const f of fs.readdirSync(resolvedDir)) { + if (basePattern.test(f) && f.toLowerCase() !== destFilename) { + fs.unlinkSync(path.join(resolvedDir, f)) + } + } + } catch { /* ignore */ } + + fs.writeFileSync(destPath, processedBuffer) + + const relPath = path.join(folderPath, destFilename) + + // cover uses the thumbnail endpoint; widecover is served directly + const url = + coverType === 'cover' + ? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}` + : `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}` + + return NextResponse.json({ url }, { status: 200 }) +} diff --git a/src/components/games/GameDetailModal.tsx b/src/components/games/GameDetailModal.tsx index 4777af5..30709ef 100644 --- a/src/components/games/GameDetailModal.tsx +++ b/src/components/games/GameDetailModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import type { Game } from '@/types' import TagSelector from '@/components/tags/TagSelector' @@ -9,14 +9,22 @@ interface Props { libraryId: string onClose: () => void onTagsChanged?: () => void + onCoverUploaded?: () => void } -export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged }: Props) { +export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded }: Props) { const overlayRef = useRef(null) + const menuRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) + const [editingImages, setEditingImages] = useState(false) useEffect(() => { const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + if (menuOpen) { setMenuOpen(false); return } + if (editingImages) { setEditingImages(false); return } + onClose() + } } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' @@ -24,13 +32,27 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } - }, [onClose]) + }, [onClose, menuOpen, editingImages]) + + // Close menu on outside click + useEffect(() => { + if (!menuOpen) return + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [menuOpen]) const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === overlayRef.current) onClose() } - const downloadHref = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(game.zipPath)}` + const zipDownloadUrl = (zipPath: string) => + `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}` + const heroImage = game.wideCoverUrl ?? game.coverUrl return (
- {/* Close button */} - + {editingImages ? ( + setEditingImages(false)} + onUploaded={onCoverUploaded} + /> + ) : ( + <> + {/* Close button */} + - {/* Wide cover / cover hero */} -
- {game.wideCoverUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${game.title} - ) : game.coverUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${game.title} - ) : ( -
๐ŸŽฎ
- )} -
+ {/* Hero image */} +
+ {heroImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${game.title} + ) : ( +
๐ŸŽฎ
+ )} +
- {/* Info */} -
-

- {game.title} -

- ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')} - onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')} - > - โ†“ - Download .zip - + {/* Info */} +
+ {/* Title row with kebab menu */} +
+

+ {game.title} +

- {/* Tags */} -
-

- Tags -

- -
-
+ {/* Kebab menu */} +
+ + {menuOpen && ( +
+ +
+ )} +
+
+ + + + {/* Tags */} +
+

+ Tags +

+ +
+
+ + )}
) } + +// โ”€โ”€โ”€ Download Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function DownloadButton({ + zipFiles, + downloadUrl, +}: { + zipFiles: string[] + downloadUrl: (zipPath: string) => string +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + const close = useCallback(() => setOpen(false), []) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) close() + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open, close]) + + const primary = zipFiles[0] + const primaryName = primary.split('/').pop() ?? primary + + if (zipFiles.length === 1) { + return ( + ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')} + onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')} + > + โ†“ + Download .zip + + ) + } + + return ( +
+
+ {/* Primary download */} + ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')} + onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')} + > + โ†“ + {primaryName} + + + {/* Divider */} +
+ + {/* Dropdown toggle */} + +
+ + {open && ( + + )} +
+ ) +} + +// โ”€โ”€โ”€ Image Editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface ImageEditorProps { + game: Game + libraryId: string + onBack: () => void + onUploaded?: () => void +} + +function ImageEditor({ game, libraryId, onBack, onUploaded }: ImageEditorProps) { + return ( +
+ {/* Header */} +
+ +

+ Edit Images +

+
+ +
+ + +
+
+ ) +} + +// โ”€โ”€โ”€ Image Slot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface ImageSlotProps { + label: string + description: string + currentUrl: string | null + fallback: string + aspectClass: string + libraryId: string + itemId: string + coverType: 'cover' | 'widecover' + onUploaded?: () => void +} + +function ImageSlot({ + label, description, currentUrl, fallback, aspectClass, + libraryId, itemId, coverType, onUploaded, +}: ImageSlotProps) { + const inputRef = useRef(null) + const [preview, setPreview] = useState(null) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + + const displayUrl = preview ?? currentUrl + + const handleChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + // Show local preview immediately + const objectUrl = URL.createObjectURL(file) + setPreview(objectUrl) + setError(null) + setUploading(true) + + const form = new FormData() + form.append('cover', file) + + try { + const res = await fetch( + `/api/game-cover?libraryId=${encodeURIComponent(libraryId)}&itemId=${encodeURIComponent(itemId)}&coverType=${coverType}`, + { method: 'POST', body: form } + ) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setError(data.error ?? 'Upload failed.') + setPreview(null) + } else { + onUploaded?.() + } + } catch { + setError('Network error.') + setPreview(null) + } finally { + setUploading(false) + e.target.value = '' + } + } + + return ( +
+
+

{label}

+

{description}

+
+ +
+ {/* Preview */} +
+ {displayUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {label} + ) : ( +
{fallback}
+ )} + {uploading && ( +
+ Savingโ€ฆ +
+ )} +
+ + {/* Controls */} +
+ + {error && ( +

{error}

+ )} +
+
+ + +
+ ) +} diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index 9a27e8e..0b2a266 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -1,7 +1,7 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' -import type { Game } from '@/types' +import { useEffect, useState, useCallback, useRef } from 'react' +import type { Game, GameSeries } from '@/types' import GameDetailModal from './GameDetailModal' import FilterPanel from '@/components/FilterPanel' @@ -10,10 +10,13 @@ interface Props { } export default function GamesView({ libraryId }: Props) { - const [games, setGames] = useState([]) + const [items, setItems] = useState<(Game | GameSeries)[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [selectedSeries, setSelectedSeries] = useState(null) const [selected, setSelected] = useState(null) + const selectedRef = useRef(selected) + selectedRef.current = selected const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) @@ -26,12 +29,26 @@ export default function GamesView({ libraryId }: Props) { return next }) - useEffect(() => { + const fetchGames = useCallback((syncSelected = false) => { fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) - .then((data) => { - setGames(data) + .then((data: (Game | GameSeries)[]) => { + setItems(data) setLoading(false) + if (syncSelected && selectedRef.current) { + const id = selectedRef.current.id + // Search top-level games and inside series + let updated: Game | undefined + for (const item of data) { + if ('games' in item) { + updated = item.games.find((g) => g.id === id) + } else if (item.id === id) { + updated = item + } + if (updated) break + } + if (updated) setSelected(updated) + } }) .catch(() => { setError('Failed to load games') @@ -39,6 +56,8 @@ export default function GamesView({ libraryId }: Props) { }) }, [libraryId]) + useEffect(() => { fetchGames() }, [fetchGames]) + const fetchAssignments = useCallback(() => { fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) .then((r) => r.json()) @@ -48,10 +67,17 @@ export default function GamesView({ libraryId }: Props) { useEffect(() => { fetchAssignments() }, [fetchAssignments]) - const filtered = games.filter((game) => { - if (search && !game.title.toLowerCase().includes(search.toLowerCase())) return false + // Items shown in the current view level + const visibleItems: (Game | GameSeries)[] = selectedSeries + ? selectedSeries.games + : items + + const filtered = visibleItems.filter((item) => { + if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0) { - const gameTags = assignments[`${libraryId}:${game.id}`] ?? [] + // Tag filtering only applies to games (series don't have tags directly) + if ('games' in item) return true + const gameTags = assignments[`${libraryId}:${item.id}`] ?? [] if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false } return true @@ -71,57 +97,51 @@ export default function GamesView({ libraryId }: Props) { />
+ {/* Breadcrumb when inside a series */} + {selectedSeries && ( +
+ + / + + {selectedSeries.title} + +
+ )} + {loading ? ( ) : error ? ( - - ) : games.length === 0 ? ( - +
+ {error} +
+ ) : items.length === 0 ? ( +
+

No games found

+

Each game should be a folder containing a .zip file.

+
) : (
- {filtered.map((game) => ( - - ))} + {filtered.map((item) => + 'games' in item ? ( + { setSelectedSeries(item); setSearch('') }} + /> + ) : ( + setSelected(item)} + /> + ) + )}
)} @@ -131,6 +151,7 @@ export default function GamesView({ libraryId }: Props) { libraryId={libraryId} onClose={() => setSelected(null)} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} + onCoverUploaded={() => fetchGames(true)} /> )}
@@ -138,6 +159,80 @@ export default function GamesView({ libraryId }: Props) { ) } +function GameCard({ game, onClick }: { game: Game; onClick: () => void }) { + return ( + + ) +} + +function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) { + return ( + + ) +} + function LoadingGrid() { return (
@@ -152,20 +247,3 @@ function LoadingGrid() {
) } - -function ErrorMessage({ message }: { message: string }) { - return ( -
- {message} -
- ) -} - -function EmptyState() { - return ( -
-

No games found

-

Each game should be a folder containing a .zip file.

-
- ) -} diff --git a/src/lib/games.ts b/src/lib/games.ts index 3a80b08..accd450 100644 --- a/src/lib/games.ts +++ b/src/lib/games.ts @@ -1,6 +1,6 @@ import fs from 'fs' import path from 'path' -import type { Game } from '@/types' +import type { Game, GameSeries } from '@/types' const HIDDEN_FILES = /^\./ @@ -25,10 +25,56 @@ function fileApiUrl(libraryId: string, relativePath: string): string { return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` } -export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] { - let gameDirs: string[] +function thumbnailApiUrl(libraryId: string, relativePath: string): string { + return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` +} + +/** + * Attempts to build a Game from a directory. + * @param absPath Absolute path to the game directory. + * @param dirName The directory's own name (used as title). + * @param relPath Path relative to the library root (used for IDs and file URLs). + * @param libraryId Library identifier. + * @returns Game, or null if the directory contains no .zip file. + */ +function buildGame( + absPath: string, + dirName: string, + relPath: string, + libraryId: string +): Game | null { + let allFiles: string[] try { - gameDirs = fs + allFiles = fs.readdirSync(absPath) + } catch { + return null + } + + const zipFiles = allFiles + .filter((f) => f.toLowerCase().endsWith('.zip')) + .sort((a, b) => a.localeCompare(b)) + if (zipFiles.length === 0) return null + + const coverFile = findFile(absPath, /^cover$/i) + const wideCoverFile = findFile(absPath, /^widecover$/i) + + return { + id: encodeURIComponent(relPath), + title: dirName, + coverUrl: coverFile + ? thumbnailApiUrl(libraryId, path.join(relPath, coverFile)) + : null, + wideCoverUrl: wideCoverFile + ? fileApiUrl(libraryId, path.join(relPath, wideCoverFile)) + : null, + zipFiles: zipFiles.map((f) => path.join(relPath, f)), + } +} + +export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] { + let topDirs: string[] + try { + topDirs = fs .readdirSync(libraryRoot, { withFileTypes: true }) .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) .map((d) => d.name) @@ -36,42 +82,66 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] return [] } - const games: Game[] = [] + const results: (Game | GameSeries)[] = [] - for (const dirName of gameDirs) { - const gamePath = path.join(libraryRoot, dirName) + for (const dirName of topDirs) { + const absPath = path.join(libraryRoot, dirName) - // Find the .zip file (first match) - let zipFile: string | null = null + let allFiles: string[] try { - const allFiles = fs.readdirSync(gamePath) - zipFile = allFiles.find((f) => f.toLowerCase().endsWith('.zip')) ?? null + allFiles = fs.readdirSync(absPath) } catch { - // skip unreadable dirs continue } - if (!zipFile) continue + // Standalone game: directory directly contains a .zip + const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip')) + if (hasZip) { + const game = buildGame(absPath, dirName, dirName, libraryId) + if (game) results.push(game) + continue + } - // Case-insensitive cover matching - const coverFile = findFile(gamePath, /^cover$/i) - const wideCoverFile = findFile(gamePath, /^widecover$/i) + // No .zip here โ€” check subdirectories (series detection) + let subDirs: string[] + try { + subDirs = fs + .readdirSync(absPath, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name)) + .map((d) => d.name) + } catch { + continue + } - const id = encodeURIComponent(dirName) - const zipRelPath = path.join(dirName, zipFile) + const seriesGames: Game[] = [] + for (const subDir of subDirs) { + const game = buildGame( + path.join(absPath, subDir), + subDir, + path.join(dirName, subDir), + libraryId + ) + if (game) seriesGames.push(game) + } - games.push({ - id, + if (seriesGames.length === 0) continue + + // It's a series โ€” check for an optional series-level cover + const seriesCoverFile = findFile(absPath, /^cover$/i) + const seriesWideCoverFile = findFile(absPath, /^widecover$/i) + + results.push({ + id: encodeURIComponent(dirName), title: dirName, - coverUrl: coverFile - ? fileApiUrl(libraryId, path.join(dirName, coverFile)) + coverUrl: seriesCoverFile + ? thumbnailApiUrl(libraryId, path.join(dirName, seriesCoverFile)) + : seriesGames[0].coverUrl, + wideCoverUrl: seriesWideCoverFile + ? fileApiUrl(libraryId, path.join(dirName, seriesWideCoverFile)) : null, - wideCoverUrl: wideCoverFile - ? fileApiUrl(libraryId, path.join(dirName, wideCoverFile)) - : null, - zipPath: zipRelPath, + games: seriesGames.sort((a, b) => a.title.localeCompare(b.title)), }) } - return games.sort((a, b) => a.title.localeCompare(b.title)) + return results.sort((a, b) => a.title.localeCompare(b.title)) } diff --git a/src/types/index.ts b/src/types/index.ts index 9148e06..4f2d51f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,7 +13,15 @@ export interface Game { title: string coverUrl: string | null wideCoverUrl: string | null - zipPath: string + zipFiles: string[] +} + +export interface GameSeries { + id: string + title: string + coverUrl: string | null + wideCoverUrl: string | null + games: Game[] } export type MediaType = 'video' | 'image' | 'other'