Add NFO previews and remove the video poster view (#515)

* Add poster view for video files

* Add NFO file previews and remove video poster view
This commit is contained in:
Jasper Van
2026-07-23 09:13:42 -04:00
committed by GitHub
parent a83c3ac5a7
commit 542b8de5c7
9 changed files with 374 additions and 1 deletions
@@ -16,6 +16,7 @@ export interface PreviewFile {
const PdfPreview = lazy(() => import('./pdf-preview').then((m) => ({ default: m.PdfPreview })))
const OfficePreview = lazy(() => import('./office-preview').then((m) => ({ default: m.OfficePreview })))
const TextPreview = lazy(() => import('./text-preview').then((m) => ({ default: m.TextPreview })))
const NfoPreview = lazy(() => import('./nfo-preview').then((m) => ({ default: m.NfoPreview })))
const MediaPreview = lazy(() => import('./media-preview').then((m) => ({ default: m.MediaPreview })))
interface PreviewDownloadButtonProps {
@@ -56,6 +57,8 @@ function FilePreviewContentInner({ file, previewType }: { file: PreviewFile; pre
case 'code':
case 'text':
return <TextPreview url={file.downloadUrl} filename={file.name} previewType={previewType} />
case 'nfo':
return <NfoPreview url={file.downloadUrl} />
case 'audio':
case 'video':
return <MediaPreview url={file.downloadUrl} filename={file.name} previewType={previewType} />
@@ -40,6 +40,7 @@ function dialogClass(previewType: PreviewType, fullscreen: boolean): string {
case 'markdown':
case 'code':
case 'text':
case 'nfo':
return 'max-w-5xl h-[85vh]'
default:
return 'max-w-3xl h-[75vh]'
+94
View File
@@ -0,0 +1,94 @@
import { FileTextIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { NfoDocument, NfoSection } from '@/lib/nfo'
import { parseNfo } from '@/lib/nfo'
interface NfoPreviewProps {
url: string
}
function Section({ section }: { section: NfoSection }) {
return (
<Card className="gap-0 py-0 shadow-none">
<CardHeader className="border-b px-4 py-3">
<CardTitle className="font-mono text-sm">{section.name}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<dl className="divide-y">
{section.fields.map((field) => (
<div key={field.name} className="grid gap-1 px-4 py-3 sm:grid-cols-[minmax(10rem,0.35fr)_1fr] sm:gap-4">
<dt className="break-words font-mono text-muted-foreground text-xs">{field.name}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words text-sm">
{field.values.map((value) => (
<div key={value}>{value}</div>
))}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)
}
function StructuredNfo({ document }: { document: Exclude<NfoDocument, { format: 'text' }> }) {
const { t } = useTranslation()
const label =
document.format === 'xml' ? t('preview.nfo.xmlFormat', { root: document.root }) : t('preview.nfo.mediaInfoFormat')
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<Badge variant="secondary">{label}</Badge>
</div>
{document.sections.map((section) => (
<Section key={section.name} section={section} />
))}
</div>
)
}
export function NfoPreview({ url }: NfoPreviewProps) {
const { t } = useTranslation()
const [document, setDocument] = useState<NfoDocument | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
const controller = new AbortController()
setDocument(null)
setError(false)
fetch(url, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`NFO download failed: ${response.status}`)
return response.text()
})
.then((content) => setDocument(parseNfo(content)))
.catch((caught) => {
if (caught instanceof DOMException && caught.name === 'AbortError') return
setError(true)
})
return () => controller.abort()
}, [url])
if (error) return <p className="p-4 text-center text-destructive">{t('preview.loadError')}</p>
if (!document) return <p className="p-4 text-center text-muted-foreground">{t('common.loading')}</p>
if (document.format === 'text') {
return (
<div>
<div className="border-b p-4">
<Badge variant="secondary">{t('preview.nfo.textFormat')}</Badge>
</div>
<pre className="whitespace-pre-wrap break-words p-4 font-mono text-sm">{document.content}</pre>
</div>
)
}
return <StructuredNfo document={document} />
}
+3
View File
@@ -1143,6 +1143,9 @@
"preview.loadError": "Failed to load file preview.",
"preview.pageOf": "{{current}} / {{total}}",
"preview.officeLocalHint": "Microsoft Office Viewer requires a public file URL. Localhost previews may not render.",
"preview.nfo.xmlFormat": "XML · {{root}}",
"preview.nfo.mediaInfoFormat": "MediaInfo",
"preview.nfo.textFormat": "Plain text NFO",
"quota.storage": "Storage",
"quota.traffic": "Traffic",
"quota.usage": "{{used}} / {{total}} used",
+3
View File
@@ -1143,6 +1143,9 @@
"preview.loadError": "加载文件预览失败。",
"preview.pageOf": "{{current}} / {{total}}",
"preview.officeLocalHint": "Microsoft Office Viewer 需要公网文件 URL,本地 localhost 预览可能无法渲染。",
"preview.nfo.xmlFormat": "XML · {{root}}",
"preview.nfo.mediaInfoFormat": "MediaInfo",
"preview.nfo.textFormat": "纯文本 NFO",
"quota.storage": "存储空间",
"quota.traffic": "下载流量",
"quota.usage": "{{used}} / {{total}} 已使用",
+7
View File
@@ -188,6 +188,13 @@ describe('getPreviewType — text extensions', () => {
})
})
describe('getPreviewType — NFO extension', () => {
it('recognizes NFO metadata files', () => {
expect(getPreviewType('movie.nfo')).toBe('nfo')
expect(getPreviewType('Episode.NFO')).toBe('nfo')
})
})
describe('getPreviewType — audio extensions', () => {
it('returns audio for .mp3', () => {
expect(getPreviewType('song.mp3')).toBe('audio')
+14 -1
View File
@@ -1,4 +1,14 @@
export type PreviewType = 'image' | 'pdf' | 'office' | 'text' | 'markdown' | 'code' | 'audio' | 'video' | 'unsupported'
export type PreviewType =
| 'image'
| 'pdf'
| 'office'
| 'text'
| 'markdown'
| 'code'
| 'nfo'
| 'audio'
| 'video'
| 'unsupported'
const extensionMap: Record<string, PreviewType> = {
// Image
@@ -59,6 +69,9 @@ const extensionMap: Record<string, PreviewType> = {
gitignore: 'text',
editorconfig: 'text',
// Media metadata
nfo: 'nfo',
// Audio
mp3: 'audio',
wav: 'audio',
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import { parseNfo } from './nfo'
describe('parseNfo', () => {
it('parses Kodi movie XML into sections', () => {
expect(
parseNfo(`
<movie>
<title>Arrival</title>
<year>2016</year>
<genre>Science Fiction</genre>
<genre>Drama</genre>
<ratings>
<rating name="imdb"><value>7.9</value></rating>
</ratings>
<actor><name>Amy Adams</name><role>Louise Banks</role></actor>
<actor><name>Jeremy Renner</name><role>Ian Donnelly</role></actor>
</movie>
`),
).toEqual({
format: 'xml',
root: 'movie',
sections: [
{
name: 'movie',
fields: [
{ name: 'title', values: ['Arrival'] },
{ name: 'year', values: ['2016'] },
{ name: 'genre', values: ['Science Fiction', 'Drama'] },
],
},
{
name: 'ratings',
fields: [{ name: 'rating (imdb) value', values: ['7.9'] }],
},
{
name: 'actor',
fields: [
{ name: 'name', values: ['Amy Adams', 'Jeremy Renner'] },
{ name: 'role', values: ['Louise Banks', 'Ian Donnelly'] },
],
},
],
})
})
it('parses episode XML without requiring a movie root', () => {
const document = parseNfo(`
<episodedetails>
<title>Sol Regem</title>
<season>3</season>
<episode>1</episode>
</episodedetails>
`)
expect(document).toMatchObject({
format: 'xml',
root: 'episodedetails',
sections: [
{
fields: [
{ name: 'title', values: ['Sol Regem'] },
{ name: 'season', values: ['3'] },
{ name: 'episode', values: ['1'] },
],
},
],
})
})
it('parses MediaInfo text reports', () => {
expect(
parseNfo(`General
Complete name : Zootopia.mp4
Duration : 1 h 48 min
Video
Format : AVC
Width : 1 920 pixels
Audio
Format : AAC
Channel(s) : 2 channels
`),
).toEqual({
format: 'mediainfo',
sections: [
{
name: 'General',
fields: [
{ name: 'Complete name', values: ['Zootopia.mp4'] },
{ name: 'Duration', values: ['1 h 48 min'] },
],
},
{
name: 'Video',
fields: [
{ name: 'Format', values: ['AVC'] },
{ name: 'Width', values: ['1 920 pixels'] },
],
},
{
name: 'Audio',
fields: [
{ name: 'Format', values: ['AAC'] },
{ name: 'Channel(s)', values: ['2 channels'] },
],
},
],
})
})
it('preserves unsupported and malformed NFO content as plain text', () => {
expect(parseNfo('Release notes')).toEqual({ format: 'text', content: 'Release notes' })
expect(parseNfo('<movie><title>Broken</movie>')).toEqual({
format: 'text',
content: '<movie><title>Broken</movie>',
})
})
})
+129
View File
@@ -0,0 +1,129 @@
export interface NfoField {
name: string
values: string[]
}
export interface NfoSection {
name: string
fields: NfoField[]
}
export type NfoDocument =
| {
format: 'xml'
root: string
sections: NfoSection[]
}
| {
format: 'mediainfo'
sections: NfoSection[]
}
| {
format: 'text'
content: string
}
function elementChildren(element: Element): Element[] {
return Array.from(element.children)
}
function elementLabel(element: Element): string {
const discriminator = element.getAttribute('type') ?? element.getAttribute('name') ?? element.getAttribute('aspect')
return discriminator ? `${element.tagName} (${discriminator})` : element.tagName
}
function addField(fields: Map<string, string[]>, name: string, value: string): void {
const normalized = value.trim()
if (!normalized) return
const values = fields.get(name) ?? []
if (!values.includes(normalized)) values.push(normalized)
fields.set(name, values)
}
function collectXmlFields(element: Element, fields: Map<string, string[]>, prefix = ''): void {
for (const child of elementChildren(element)) {
const label = elementLabel(child)
const name = prefix ? `${prefix} ${label}` : label
const children = elementChildren(child)
if (children.length === 0) {
addField(fields, name, child.textContent ?? '')
} else {
collectXmlFields(child, fields, name)
}
}
}
function fieldsFromMap(fields: Map<string, string[]>): NfoField[] {
return Array.from(fields, ([name, values]) => ({ name, values }))
}
function parseXmlNfo(content: string): NfoDocument | null {
const document = new DOMParser().parseFromString(content, 'application/xml')
if (document.getElementsByTagName('parsererror').length > 0) return null
const root = document.documentElement
if (!root) return null
const overview = new Map<string, string[]>()
const sectionFields = new Map<string, Map<string, string[]>>()
for (const child of elementChildren(root)) {
const children = elementChildren(child)
if (children.length === 0) {
addField(overview, elementLabel(child), child.textContent ?? '')
continue
}
const sectionName = child.tagName
const fields = sectionFields.get(sectionName) ?? new Map<string, string[]>()
collectXmlFields(child, fields)
sectionFields.set(sectionName, fields)
}
const sections: NfoSection[] = []
if (overview.size > 0) sections.push({ name: root.tagName, fields: fieldsFromMap(overview) })
for (const [name, fields] of sectionFields) {
sections.push({ name, fields: fieldsFromMap(fields) })
}
return { format: 'xml', root: root.tagName, sections }
}
function parseMediaInfoNfo(content: string): NfoDocument | null {
const sections: NfoSection[] = []
let current: NfoSection | null = null
for (const line of content.split(/\r?\n/)) {
const field = line.match(/^([^:]+?)\s+:\s*(.*)$/)
if (field && current) {
const [, name, value] = field
if (name && value) current.fields.push({ name: name.trim(), values: [value.trim()] })
continue
}
const sectionName = line.trim()
if (sectionName && !line.includes(':')) {
current = { name: sectionName, fields: [] }
sections.push(current)
}
}
const populatedSections = sections.filter((section) => section.fields.length > 0)
if (populatedSections.length === 0 || !populatedSections.some((section) => section.name === 'General')) return null
return { format: 'mediainfo', sections: populatedSections }
}
export function parseNfo(content: string): NfoDocument {
const trimmed = content.trim()
if (trimmed.startsWith('<')) {
const xml = parseXmlNfo(trimmed)
if (xml) return xml
}
const mediaInfo = parseMediaInfoNfo(content)
if (mediaInfo) return mediaInfo
return { format: 'text', content }
}