File: id3pic.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2020-2025 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 Single-file source-code for id3pic.
  27 
  28 To compile a smaller-sized command-line app, you can use the `go` command as
  29 follows:
  30 
  31 go build -ldflags "-s -w" -trimpath id3pic.go
  32 */
  33 
  34 package main
  35 
  36 import (
  37     "bufio"
  38     "encoding/binary"
  39     "errors"
  40     "io"
  41     "os"
  42 )
  43 
  44 const info = `
  45 id3pic [options...] [file...]
  46 
  47 Extract picture/thumbnail bytes from ID3/MP3 metadata, if available.
  48 
  49 All (optional) leading options start with either single or double-dash:
  50 
  51     -h          show this help message
  52     -help       show this help message
  53 `
  54 
  55 // errNoMoreOutput is a dummy error whose message is ignored, and which
  56 // causes the app to quit immediately and successfully
  57 var errNoMoreOutput = errors.New(`no more output`)
  58 
  59 // errNoThumb is a generic error to handle lack of thumbnails, in case no
  60 // picture-metadata-starters are found at all
  61 var errNoThumb = errors.New(`no thumbnail data found`)
  62 
  63 // errInvalidPIC is a generic error for invalid PIC-format pics
  64 var errInvalidPIC = errors.New(`invalid PIC-format embedded thumbnail`)
  65 
  66 func main() {
  67     if len(os.Args) > 1 {
  68         switch os.Args[1] {
  69         case `-h`, `--h`, `-help`, `--help`:
  70             os.Stderr.WriteString(info[1:])
  71             return
  72         }
  73     }
  74 
  75     if len(os.Args) > 2 {
  76         showError(`can only handle 1 file`)
  77         os.Exit(1)
  78     }
  79 
  80     name := `-`
  81     if len(os.Args) > 1 {
  82         name = os.Args[1]
  83     }
  84 
  85     if err := run(os.Stdout, name); isActualError(err) {
  86         showError(err.Error())
  87         os.Exit(1)
  88     }
  89 }
  90 
  91 func showError(msg string) {
  92     os.Stderr.WriteString("\x1b[31m")
  93     os.Stderr.WriteString(msg)
  94     os.Stderr.WriteString("\x1b[0m\n")
  95 }
  96 
  97 func run(w io.Writer, name string) error {
  98     if name == `-` {
  99         return id3pic(w, bufio.NewReader(os.Stdin))
 100     }
 101 
 102     f, err := os.Open(name)
 103     if err != nil {
 104         return errors.New(`can't read from file named "` + name + `"`)
 105     }
 106     defer f.Close()
 107 
 108     return id3pic(w, bufio.NewReader(f))
 109 }
 110 
 111 // isActualError is to figure out whether not to ignore an error, and thus
 112 // show it as an error message
 113 func isActualError(err error) bool {
 114     return err != nil && err != io.EOF && err != errNoMoreOutput
 115 }
 116 
 117 func match(r *bufio.Reader, what []byte) bool {
 118     for _, v := range what {
 119         b, err := r.ReadByte()
 120         if b != v || err != nil {
 121             return false
 122         }
 123     }
 124     return true
 125 }
 126 
 127 func id3pic(w io.Writer, r *bufio.Reader) error {
 128     for {
 129         b, err := r.ReadByte()
 130         if err == io.EOF {
 131             return errNoThumb
 132         }
 133         if err != nil {
 134             return err
 135         }
 136 
 137         // handle APIC-type chunks
 138         if b == 'A' && match(r, []byte{'P', 'I', 'C'}) {
 139             return handleAPIC(w, r)
 140         }
 141 
 142         // handle PIC-type chunks
 143         if b == 'P' && match(r, []byte{'I', 'C'}) {
 144             return handlePIC(w, r)
 145         }
 146     }
 147 }
 148 
 149 func handleAPIC(w io.Writer, r *bufio.Reader) error {
 150     // section-size seems stored as 4 big-endian bytes
 151     var size uint32
 152     err := binary.Read(r, binary.BigEndian, &size)
 153     if err != nil {
 154         return err
 155     }
 156 
 157     n, err := skipThumbnailTypeAPIC(r)
 158     if err != nil {
 159         return err
 160     }
 161 
 162     _, err = io.Copy(w, io.LimitReader(r, int64(int(size)-n)))
 163     return err
 164 }
 165 
 166 func skipThumbnailTypeAPIC(r *bufio.Reader) (skipped int, err error) {
 167     // https://id3.org/id3v2.3.0
 168 
 169     m, err := r.Discard(2)
 170     if err != nil || m != 2 {
 171         return -1, errors.New(`failed to sync APIC flags`)
 172     }
 173     skipped += m
 174 
 175     m, err = r.Discard(1)
 176     if err != nil || m != 1 {
 177         return -1, errors.New(`failed to sync APIC text-encoding`)
 178     }
 179     skipped += m
 180 
 181     junk, err := r.ReadSlice(0)
 182     if err != nil {
 183         return -1, errors.New(`failed to sync to APIC thumbnail MIME-type`)
 184     }
 185     skipped += len(junk)
 186 
 187     m, err = r.Discard(1)
 188     if err != nil || m != 1 {
 189         return -1, errors.New(`failed to sync APIC picture type`)
 190     }
 191     skipped += m
 192 
 193     junk, err = r.ReadSlice(0)
 194     if err != nil {
 195         return -1, errors.New(`failed to sync to APIC thumbnail description`)
 196     }
 197     skipped += len(junk)
 198 
 199     return skipped, nil
 200 }
 201 
 202 func handlePIC(w io.Writer, r *bufio.Reader) error {
 203     // http://www.unixgods.org/Ruby/ID3/docs/id3v2-00.html#PIC
 204 
 205     // thumbnail-payload-size seems stored as 3 big-endian bytes
 206     a, err1 := r.ReadByte()
 207     b, err2 := r.ReadByte()
 208     c, err3 := r.ReadByte()
 209     if err1 != nil || err2 != nil || err3 != nil {
 210         return errors.New(`failed to read PIC thumbnail size`)
 211     }
 212 
 213     // skip the text encoding
 214     n, err := r.Discard(5)
 215     if n != 5 || err != nil {
 216         return errInvalidPIC
 217     }
 218 
 219     // skip a null-delimited string
 220     _, err = r.ReadSlice(0)
 221     if err != nil {
 222         return errInvalidPIC
 223     }
 224 
 225     size := int64(a)<<16 + int64(b)<<8 + int64(c)
 226     _, err = io.Copy(w, io.LimitReader(r, size))
 227     return err
 228 }