mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 01:51:11 +08:00
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:
@@ -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]'
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}} 已使用",
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user