File: id3pic.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright (c) 2026 pacman64
   5 
   6 Permission is hereby granted, free of charge, to any person obtaining a copy of
   7 this software and associated documentation files (the "Software"), to deal
   8 in the Software without restriction, including without limitation the rights to
   9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
  10 of the Software, and to permit persons to whom the Software is furnished to do
  11 so, subject to the following conditions:
  12 
  13 The above copyright notice and this permission notice shall be included in all
  14 copies or substantial portions of the Software.
  15 
  16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22 SOFTWARE.
  23 */
  24 
  25 /*
  26 To compile a smaller-sized command-line app, you can use the `go` command as
  27 follows:
  28 
  29 go build -ldflags "-s -w" -trimpath id3pic.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "encoding/binary"
  37     "errors"
  38     "io"
  39     "os"
  40 )
  41 
  42 const info = `
  43 id3pic [options...] [file...]
  44 
  45 Extract picture/thumbnail bytes from ID3/MP3 metadata, if available.
  46 
  47 All (optional) leading options start with either single or double-dash:
  48 
  49     -h, -help    show this help message
  50 `
  51 
  52 // errNoThumb is a generic error to handle lack of thumbnails, in case no
  53 // picture-metadata-starters are found at all
  54 var errNoThumb = errors.New(`no thumbnail data found`)
  55 
  56 // errInvalidPIC is a generic error for invalid PIC-format pics
  57 var errInvalidPIC = errors.New(`invalid PIC-format embedded thumbnail`)
  58 
  59 func main() {
  60     if len(os.Args) > 1 {
  61         switch os.Args[1] {
  62         case `-h`, `--h`, `-help`, `--help`:
  63             os.Stdout.WriteString(info[1:])
  64             return
  65         }
  66     }
  67 
  68     if len(os.Args) > 2 {
  69         showError(`can only handle 1 file`)
  70         os.Exit(1)
  71     }
  72 
  73     name := `-`
  74     if len(os.Args) > 1 {
  75         name = os.Args[1]
  76     }
  77 
  78     if err := run(os.Stdout, name); err != nil && err != io.EOF {
  79         showError(err.Error())
  80         os.Exit(1)
  81     }
  82 }
  83 
  84 func showError(msg string) {
  85     os.Stderr.WriteString(msg)
  86     os.Stderr.WriteString("\n")
  87 }
  88 
  89 func run(w io.Writer, name string) error {
  90     if name == `-` {
  91         return id3pic(w, bufio.NewReader(os.Stdin))
  92     }
  93 
  94     f, err := os.Open(name)
  95     if err != nil {
  96         return errors.New(`can't read from file named "` + name + `"`)
  97     }
  98     defer f.Close()
  99 
 100     return id3pic(w, bufio.NewReader(f))
 101 }
 102 
 103 func match(r *bufio.Reader, what []byte) bool {
 104     for _, v := range what {
 105         b, err := r.ReadByte()
 106         if b != v || err != nil {
 107             return false
 108         }
 109     }
 110     return true
 111 }
 112 
 113 func id3pic(w io.Writer, r *bufio.Reader) error {
 114     // match the ID3 mark
 115     for {
 116         b, err := r.ReadByte()
 117         if err == io.EOF {
 118             return errNoThumb
 119         }
 120         if err != nil {
 121             return err
 122         }
 123 
 124         if b == 'I' && match(r, []byte{'D', '3'}) {
 125             break
 126         }
 127     }
 128 
 129     for {
 130         b, err := r.ReadByte()
 131         if err == io.EOF {
 132             return errNoThumb
 133         }
 134         if err != nil {
 135             return err
 136         }
 137 
 138         // handle APIC-type chunks
 139         if b == 'A' && match(r, []byte{'P', 'I', 'C'}) {
 140             return handleAPIC(w, r)
 141         }
 142     }
 143 }
 144 
 145 func handleAPIC(w io.Writer, r *bufio.Reader) error {
 146     // section-size seems stored as 4 big-endian bytes
 147     var size uint32
 148     err := binary.Read(r, binary.BigEndian, &size)
 149     if err != nil {
 150         return err
 151     }
 152 
 153     n, err := skipThumbnailTypeAPIC(r)
 154     if err != nil {
 155         return err
 156     }
 157 
 158     _, err = io.Copy(w, io.LimitReader(r, int64(int(size)-n)))
 159     return err
 160 }
 161 
 162 func skipThumbnailTypeAPIC(r *bufio.Reader) (skipped int, err error) {
 163     m, err := r.Discard(2)
 164     if err != nil || m != 2 {
 165         return -1, errors.New(`failed to sync APIC flags`)
 166     }
 167     skipped += m
 168 
 169     m, err = r.Discard(1)
 170     if err != nil || m != 1 {
 171         return -1, errors.New(`failed to sync APIC text-encoding`)
 172     }
 173     skipped += m
 174 
 175     junk, err := r.ReadSlice(0)
 176     if err != nil {
 177         return -1, errors.New(`failed to sync to APIC thumbnail MIME-type`)
 178     }
 179     skipped += len(junk)
 180 
 181     m, err = r.Discard(1)
 182     if err != nil || m != 1 {
 183         return -1, errors.New(`failed to sync APIC picture type`)
 184     }
 185     skipped += m
 186 
 187     junk, err = r.ReadSlice(0)
 188     if err != nil {
 189         return -1, errors.New(`failed to sync to APIC thumbnail description`)
 190     }
 191     skipped += len(junk)
 192 
 193     return skipped, nil
 194 }