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          show this help message
  50     -help       show this help message
  51 `
  52 
  53 // errNoThumb is a generic error to handle lack of thumbnails, in case no
  54 // picture-metadata-starters are found at all
  55 var errNoThumb = errors.New(`no thumbnail data found`)
  56 
  57 // errInvalidPIC is a generic error for invalid PIC-format pics
  58 var errInvalidPIC = errors.New(`invalid PIC-format embedded thumbnail`)
  59 
  60 func main() {
  61     if len(os.Args) > 1 {
  62         switch os.Args[1] {
  63         case `-h`, `--h`, `-help`, `--help`:
  64             os.Stdout.WriteString(info[1:])
  65             return
  66         }
  67     }
  68 
  69     if len(os.Args) > 2 {
  70         showError(`can only handle 1 file`)
  71         os.Exit(1)
  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     }
  83 }
  84 
  85 func showError(msg string) {
  86     os.Stderr.WriteString(msg)
  87     os.Stderr.WriteString("\n")
  88 }
  89 
  90 func run(w io.Writer, name string) error {
  91     if name == `-` {
  92         return id3pic(w, bufio.NewReader(os.Stdin))
  93     }
  94 
  95     f, err := os.Open(name)
  96     if err != nil {
  97         return errors.New(`can't read from file named "` + name + `"`)
  98     }
  99     defer f.Close()
 100 
 101     return id3pic(w, bufio.NewReader(f))
 102 }
 103 
 104 func match(r *bufio.Reader, what []byte) bool {
 105     for _, v := range what {
 106         b, err := r.ReadByte()
 107         if b != v || err != nil {
 108             return false
 109         }
 110     }
 111     return true
 112 }
 113 
 114 func id3pic(w io.Writer, r *bufio.Reader) error {
 115     // match the ID3 mark
 116     for {
 117         b, err := r.ReadByte()
 118         if err == io.EOF {
 119             return errNoThumb
 120         }
 121         if err != nil {
 122             return err
 123         }
 124 
 125         if b == 'I' && match(r, []byte{'D', '3'}) {
 126             break
 127         }
 128     }
 129 
 130     for {
 131         b, err := r.ReadByte()
 132         if err == io.EOF {
 133             return errNoThumb
 134         }
 135         if err != nil {
 136             return err
 137         }
 138 
 139         // handle APIC-type chunks
 140         if b == 'A' && match(r, []byte{'P', 'I', 'C'}) {
 141             return handleAPIC(w, r)
 142         }
 143     }
 144 }
 145 
 146 func handleAPIC(w io.Writer, r *bufio.Reader) error {
 147     // section-size seems stored as 4 big-endian bytes
 148     var size uint32
 149     err := binary.Read(r, binary.BigEndian, &size)
 150     if err != nil {
 151         return err
 152     }
 153 
 154     n, err := skipThumbnailTypeAPIC(r)
 155     if err != nil {
 156         return err
 157     }
 158 
 159     _, err = io.Copy(w, io.LimitReader(r, int64(int(size)-n)))
 160     return err
 161 }
 162 
 163 func skipThumbnailTypeAPIC(r *bufio.Reader) (skipped int, err error) {
 164     m, err := r.Discard(2)
 165     if err != nil || m != 2 {
 166         return -1, errors.New(`failed to sync APIC flags`)
 167     }
 168     skipped += m
 169 
 170     m, err = r.Discard(1)
 171     if err != nil || m != 1 {
 172         return -1, errors.New(`failed to sync APIC text-encoding`)
 173     }
 174     skipped += m
 175 
 176     junk, err := r.ReadSlice(0)
 177     if err != nil {
 178         return -1, errors.New(`failed to sync to APIC thumbnail MIME-type`)
 179     }
 180     skipped += len(junk)
 181 
 182     m, err = r.Discard(1)
 183     if err != nil || m != 1 {
 184         return -1, errors.New(`failed to sync APIC picture type`)
 185     }
 186     skipped += m
 187 
 188     junk, err = r.ReadSlice(0)
 189     if err != nil {
 190         return -1, errors.New(`failed to sync to APIC thumbnail description`)
 191     }
 192     skipped += len(junk)
 193 
 194     return skipped, nil
 195 }