Add rich preview support for three new file categories by leveraging external CLI tools with graceful fallback when tools are missing. - PDF: text extraction via pdftotext, page count via pdfinfo - Audio: metadata via ffprobe (duration, bitrate, codec, sample rate, channels) - Video: metadata via ffprobe (duration, bitrate, video/audio codec, resolution) - New PreviewKind constants: PDF, Audio, Video - New Metadata fields for extended preview data - New extension maps and Category() entries for pdf/audio/video - Icons: PDF (), audio (), video () in preview header Closes #5
17 KiB
Extended Preview — PDF, Audio, Video via External Utilities
Overview
Add rich preview support for three new file categories by leveraging external CLI tools. If a tool is not installed, the file falls back to the existing binary-file display.
flowchart TD
A[BuildPreview entry] --> B{Is directory?}
B -->|Yes| C[buildDirectoryPreview]
B -->|No| D[Open file, read header]
D --> E{Detect image?}
E -->|Yes| F[PreviewKindImage]
E -->|No| G{Detect PDF ext?}
G -->|Yes| H{pdftotext available?}
H -->|Yes| I[PreviewKindPDF - extract text]
H -->|No| J[Fallback to binary]
G -->|No| K{Detect audio ext?}
K -->|Yes| L{ffprobe available?}
L -->|Yes| M[PreviewKindAudio - show metadata]
L -->|No| N[Fallback to binary]
K -->|No| O{Detect video ext?}
O -->|Yes| P{ffprobe available?}
P -->|Yes| Q[PreviewKindVideo - show metadata]
P -->|No| R[Fallback to binary]
O -->|No| S[Is binary sample?]
S -->|Yes| T[PreviewKindBinary]
S -->|No| U[PreviewKindText + syntax highlight]
1. New PreviewKind Constants
File: internal/fs/preview.go (around line 28-35)
Add three new constants to the PreviewKind type:
PreviewKindPDF PreviewKind = "pdf"PreviewKindAudio PreviewKind = "audio"PreviewKindVideo PreviewKind = "video"
2. Extended Metadata Struct
File: internal/fs/preview.go — Metadata struct (line 37-48)
Add new optional fields:
type Metadata struct {
// ... existing fields ...
// Extended preview metadata
Duration string // audio/video duration (e.g. "3:42")
Bitrate string // audio/video bitrate (e.g. "320 kbps")
AudioCodec string // audio codec (e.g. "aac", "mp3")
VideoCodec string // video codec (e.g. "h264", "vp9")
SampleRate string // audio sample rate (e.g. "44100 Hz")
Channels string // audio channels (e.g. "stereo")
PageCount string // PDF page count
Dimensions string // video dimensions (e.g. "1920x1080")
}
3. New Extension Maps
File: internal/fs/entry.go (around line 55-63)
Add three new extension sets:
pdfExtensions = map[string]struct{}{
"pdf": {},
}
audioExtensions = map[string]struct{}{
"mp3": {}, "flac": {}, "ogg": {}, "opus": {}, "wav": {},
"aac": {}, "m4a": {}, "wma": {}, "dsf": {}, "ape": {},
}
videoExtensions = map[string]struct{}{
"mp4": {}, "mkv": {}, "mov": {}, "avi": {}, "webm": {},
"m4v": {}, "wmv": {}, "flv": {}, "ts": {}, "mts": {},
}
4. New Categories in Category() Method
File: internal/fs/entry.go — Category() method (line 102-123)
Add three new cases in the switch statement (before default):
case hasExt(pdfExtensions, e.Extension):
return "pdf"
case hasExt(audioExtensions, e.Extension):
return "audio"
case hasExt(videoExtensions, e.Extension):
return "video"
5. External Utility Detection
File: internal/fs/preview.go — new helper functions
func findTool(name string) string {
path, err := exec.LookPath(name)
if err != nil {
return ""
}
return path
}
Add "os/exec" to imports.
6. Modified BuildPreview() Flow
File: internal/fs/preview.go — BuildPreview() (line 73)
Insert the new checks after the image detection block (after line 131) and before the IsBinarySample() check:
// PDF preview via pdftotext
if hasExt(pdfExtensions, entry.Extension) {
return buildPDFPreview(entry, options, preview)
}
// Audio preview via ffprobe
if hasExt(audioExtensions, entry.Extension) {
return buildAudioPreview(entry, options, preview)
}
// Video preview via ffprobe
if hasExt(videoExtensions, entry.Extension) {
return buildVideoPreview(entry, options, preview)
}
This placement ensures:
- Images are still detected by magic bytes (works for any extension)
- PDF/audio/video get their custom handling
- Files that don't match any category fall through to the existing binary/text detection
7. New Preview Builder Functions
File: internal/fs/preview.go — new functions
7a. buildPDFPreview()
func buildPDFPreview(entry Entry, options PreviewOptions, base Preview) Preview {
pdftotext := findTool("pdftotext")
if pdftotext == "" {
base.Kind = PreviewKindBinary
base.Body = "PDF file detected.\nInstall poppler-utils (pdftotext) for text preview."
base.PlainBody = base.Body
return base
}
// Extract text
cmd := exec.Command(pdftotext, "-layout", "-nopgbrk", entry.Path, "-")
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("pdftotext error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
text := string(out)
if len(text) > int(options.MaxPreviewBytes) {
text = text[:options.MaxPreviewBytes]
}
// Get page count via pdfinfo if available
pdfinfo := findTool("pdfinfo")
if pdfinfo != "" {
infoCmd := exec.Command(pdfinfo, entry.Path)
if infoOut, err := infoCmd.Output(); err == nil {
for _, line := range strings.Split(string(infoOut), "\n") {
if strings.HasPrefix(strings.ToLower(line), "pages:") {
base.Metadata.PageCount = strings.TrimSpace(strings.TrimPrefix(line, "Pages:"))
break
}
}
}
}
base.Kind = PreviewKindPDF
base.PlainBody = text
base.Body = highlightText(entry.Path, text, options.ThemeName)
return base
}
7b. buildAudioPreview()
func buildAudioPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Audio file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
// Use ffprobe to extract metadata as JSON
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
mins := int(secs) / 60
secsRem := int(secs) % 60
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
if stream.CodecType == "audio" {
base.Metadata.AudioCodec = stream.CodecName
if stream.SampleRate != "" {
base.Metadata.SampleRate = stream.SampleRate + " Hz"
}
switch stream.Channels {
case 1:
base.Metadata.Channels = "mono"
case 2:
base.Metadata.Channels = "stereo"
case 6:
base.Metadata.Channels = "5.1"
case 8:
base.Metadata.Channels = "7.1"
default:
base.Metadata.Channels = fmt.Sprintf("%d ch", stream.Channels)
}
break
}
}
// Build a rich metadata body
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Codec: %s", base.Metadata.AudioCodec))
}
if base.Metadata.SampleRate != "" {
lines = append(lines, fmt.Sprintf(" Rate: %s", base.Metadata.SampleRate))
}
if base.Metadata.Channels != "" {
lines = append(lines, fmt.Sprintf(" Channels: %s", base.Metadata.Channels))
}
base.Kind = PreviewKindAudio
base.Body = fmt.Sprintf("🎵 Audio File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Audio File\n\nDuration: %s\nBitrate: %s\nCodec: %s\nRate: %s\nChannels: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.AudioCodec,
base.Metadata.SampleRate, base.Metadata.Channels)
return base
}
7c. buildVideoPreview()
func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Video file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
// Use ffprobe to extract metadata as JSON
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
hrs := int(secs) / 3600
mins := (int(secs) % 3600) / 60
secsRem := int(secs) % 60
if hrs > 0 {
base.Metadata.Duration = fmt.Sprintf("%d:%02d:%02d", hrs, mins, secsRem)
} else {
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
switch stream.CodecType {
case "video":
base.Metadata.VideoCodec = stream.CodecName
if stream.Width > 0 && stream.Height > 0 {
base.Metadata.Dimensions = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
if base.Metadata.AudioCodec == "" {
base.Metadata.AudioCodec = stream.CodecName
}
}
}
// Build a rich metadata body
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.VideoCodec != "" {
lines = append(lines, fmt.Sprintf(" Video: %s", base.Metadata.VideoCodec))
}
if base.Metadata.Dimensions != "" {
lines = append(lines, fmt.Sprintf(" Resolution: %s", base.Metadata.Dimensions))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Audio: %s", base.Metadata.AudioCodec))
}
base.Kind = PreviewKindVideo
base.Body = fmt.Sprintf("🎬 Video File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Video File\n\nDuration: %s\nBitrate: %s\nVideo: %s\nResolution: %s\nAudio: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.VideoCodec,
base.Metadata.Dimensions, base.Metadata.AudioCodec)
return base
}
8. UI: Update renderMetadata()
File: internal/ui/model.go — renderMetadata() (line 2714)
Add new metadata fields to the right column after the image info block:
// After: if meta.ImageFormat != "" { ... }
if meta.Duration != "" {
rightRows = append(rightRows, fmt.Sprintf("duration: %s", meta.Duration))
}
if meta.Bitrate != "" {
rightRows = append(rightRows, fmt.Sprintf("bitrate: %s", meta.Bitrate))
}
if meta.AudioCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("audio: %s", meta.AudioCodec))
}
if meta.VideoCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("video: %s", meta.VideoCodec))
}
if meta.Dimensions != "" {
rightRows = append(rightRows, fmt.Sprintf("resolution: %s", meta.Dimensions))
}
if meta.SampleRate != "" {
rightRows = append(rightRows, fmt.Sprintf("rate: %s", meta.SampleRate))
}
if meta.Channels != "" {
rightRows = append(rightRows, fmt.Sprintf("channels: %s", meta.Channels))
}
if meta.PageCount != "" {
rightRows = append(rightRows, fmt.Sprintf("pages: %s", meta.PageCount))
}
9. UI: Update previewIcon()
File: internal/ui/model.go — previewIcon() (line 3545)
Add new Nerd Font icons:
case vfs.PreviewKindPDF:
return "" // nf-oct-file-pdf
case vfs.PreviewKindAudio:
return "" // nf-custom-audio-file
case vfs.PreviewKindVideo:
return "" // nf-custom-video-file
And ASCII fallback:
case "pdf":
return "[P]"
case "audio":
return "[A]"
case "video":
return "[V]"
10. UI: Update syncImageOverlay()
File: internal/ui/model.go — syncImageOverlay() (line 4225)
The overlay should only show for PreviewKindImage, which it already checks. No change needed — video files should NOT use the image overlay since we don't have video thumbnail support yet.
11. Files Changed Summary
| File | Changes |
|---|---|
internal/fs/entry.go |
Add pdfExtensions, audioExtensions, videoExtensions maps; update Category() method |
internal/fs/preview.go |
Add PreviewKindPDF/Audio/Video constants; add fields to Metadata; add findTool(), buildPDFPreview(), buildAudioPreview(), buildVideoPreview(); modify BuildPreview() insertion point; add "os/exec", "encoding/json" imports |
internal/ui/model.go |
Update renderMetadata() with new fields; update previewIcon() with new icons |
12. Edge Cases & Considerations
- Missing external tool: If
pdftotextorffprobeis not installed, the user sees a helpful message telling them which package to install, and the preview falls back toPreviewKindBinary(same as before). - Large PDFs: Text extraction respects
MaxPreviewByteslimit — truncated output is still highlighted. - Corrupt files:
ffprobeandpdftotexthandle errors gracefully; any non-zero exit returnsPreviewKindErrorwith the error message. - Performance: External processes are launched synchronously in the preview command (which already runs in a goroutine via
loadPreviewCmd()), so the UI remains responsive. - No new dependencies: All tools are invoked via
os/exec(stdlib). No Go modules needed.
13. Test Plan
- Open
test.pdf— verify text is extracted and syntax-highlighted, page count shown in metadata - Open
test.mp3— verify duration, bitrate, codec, sample rate, channels shown - Open
test.flac— same as above - Open
test.mp4— verify duration, bitrate, video codec, resolution, audio codec shown - Open PDF/audio/video on a system without
pdftotext/ffprobe— verify fallback message - Verify
go build ./...andgo vet ./...pass