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         return
  72     }
  73 
  74     name := `-`
  75     if len(os.Args) > 1 {
  76         name = os.Args[1]
  77     }
  78 
  79     if err := run(os.Stdout, name); err != nil && err != io.EOF {
  80         showError(err.Error())
  81         os.Exit(1)
  82         return
  83     }
  84 }
  85 
  86 func showError(msg string) {
  87     os.Stderr.WriteString(msg)
  88     os.Stderr.WriteString("\n")
  89 }
  90 
  91 func run(w io.Writer, name string) error {
  92     if name == `-` {
  93         return id3pic(w, bufio.NewReader(os.Stdin))
  94     }
  95 
  96     f, err := os.Open(name)
  97     if err != nil {
  98         return errors.New(`can't read from file named "` + name + `"`)
  99     }
 100     defer f.Close()
 101 
 102     return id3pic(w, bufio.NewReader(f))
 103 }
 104 
 105 func match(r *bufio.Reader, what []byte) bool {
 106     for _, v := range what {
 107         b, err := r.ReadByte()
 108         if b != v || err != nil {
 109             return false
 110         }
 111     }
 112     return true
 113 }
 114 
 115 func id3pic(w io.Writer, r *bufio.Reader) error {
 116     // match the ID3 mark
 117     for {
 118         b, err := r.ReadByte()
 119         if err == io.EOF {
 120             return errNoThumb
 121         }
 122         if err != nil {
 123             return err
 124         }
 125 
 126         if b == 'I' && match(r, []byte{'D', '3'}) {
 127             break
 128         }
 129     }
 130 
 131     for {
 132         b, err := r.ReadByte()
 133         if err == io.EOF {
 134             return errNoThumb
 135         }
 136         if err != nil {
 137             return err
 138         }
 139 
 140         // handle APIC-type chunks
 141         if b == 'A' && match(r, []byte{'P', 'I', 'C'}) {
 142             return handleAPIC(w, r)
 143         }
 144     }
 145 }
 146 
 147 func handleAPIC(w io.Writer, r *bufio.Reader) error {
 148     // section-size seems stored as 4 big-endian bytes
 149     var size uint32
 150     err := binary.Read(r, binary.BigEndian, &size)
 151     if err != nil {
 152         return err
 153     }
 154 
 155     n, err := skipThumbnailTypeAPIC(r)
 156     if err != nil {
 157         return err
 158     }
 159 
 160     _, err = io.Copy(w, io.LimitReader(r, int64(int(size)-n)))
 161     return err
 162 }
 163 
 164 func skipThumbnailTypeAPIC(r *bufio.Reader) (skipped int, err error) {
 165     m, err := r.Discard(2)
 166     if err != nil || m != 2 {
 167         return -1, errors.New(`failed to sync APIC flags`)
 168     }
 169     skipped += m
 170 
 171     m, err = r.Discard(1)
 172     if err != nil || m != 1 {
 173         return -1, errors.New(`failed to sync APIC text-encoding`)
 174     }
 175     skipped += m
 176 
 177     junk, err := r.ReadSlice(0)
 178     if err != nil {
 179         return -1, errors.New(`failed to sync to APIC thumbnail MIME-type`)
 180     }
 181     skipped += len(junk)
 182 
 183     m, err = r.Discard(1)
 184     if err != nil || m != 1 {
 185         return -1, errors.New(`failed to sync APIC picture type`)
 186     }
 187     skipped += m
 188 
 189     junk, err = r.ReadSlice(0)
 190     if err != nil {
 191         return -1, errors.New(`failed to sync to APIC thumbnail description`)
 192     }
 193     skipped += len(junk)
 194 
 195     return skipped, nil
 196 }