vcom/plans/extended-preview-feature.md
vrubelroman a483267bd9 feat: extended preview for PDF, audio, video via external utilities
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
2026-04-27 19:25:03 +03:00

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.goMetadata 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.goCategory() 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.goBuildPreview() (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.gorenderMetadata() (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.gopreviewIcon() (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.gosyncImageOverlay() (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 pdftotext or ffprobe is not installed, the user sees a helpful message telling them which package to install, and the preview falls back to PreviewKindBinary (same as before).
  • Large PDFs: Text extraction respects MaxPreviewBytes limit — truncated output is still highlighted.
  • Corrupt files: ffprobe and pdftotext handle errors gracefully; any non-zero exit returns PreviewKindError with 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

  1. Open test.pdf — verify text is extracted and syntax-highlighted, page count shown in metadata
  2. Open test.mp3 — verify duration, bitrate, codec, sample rate, channels shown
  3. Open test.flac — same as above
  4. Open test.mp4 — verify duration, bitrate, video codec, resolution, audio codec shown
  5. Open PDF/audio/video on a system without pdftotext/ffprobe — verify fallback message
  6. Verify go build ./... and go vet ./... pass